Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "codeowners"
version = "0.2.10"
version = "0.2.11"
edition = "2024"

[profile.release]
Expand Down
7 changes: 7 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pub struct Config {

#[serde(default = "default_ignore_dirs")]
pub ignore_dirs: Vec<String>,

#[serde(default = "default_skip_untracked_files")]
pub skip_untracked_files: bool,
}

#[allow(dead_code)]
Expand Down Expand Up @@ -60,6 +63,10 @@ fn vendored_gems_path() -> String {
"vendored/".to_string()
}

fn default_skip_untracked_files() -> bool {
true
}

fn default_ignore_dirs() -> Vec<String> {
vec![
".cursor".to_owned(),
Expand Down
157 changes: 157 additions & 0 deletions src/files.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
use std::{
path::{Path, PathBuf},
process::Command,
};

use core::fmt;
use error_stack::{Context, Result, ResultExt};

#[derive(Debug)]
pub enum Error {
Io,
}

impl fmt::Display for Error {
fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Io => fmt.write_str("Error::Io"),
}
}
}

impl Context for Error {}

pub(crate) fn untracked_files(base_path: &Path) -> Result<Vec<PathBuf>, Error> {
let output = Command::new("git")
.args(["ls-files", "--others", "--exclude-standard", "--full-name", "-z", "--", "."])
.current_dir(base_path)
.output()
.change_context(Error::Io)?;

if !output.status.success() {
return Ok(Vec::new());
}

let results: Vec<PathBuf> = output
.stdout
.split(|&b| b == b'\0')
.filter(|chunk| !chunk.is_empty())
.map(|rel| std::str::from_utf8(rel).change_context(Error::Io).map(|s| base_path.join(s)))
.collect::<std::result::Result<_, _>>()?;

Ok(results)
}

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

#[test]
fn test_untracked_files() {
let tmp_dir = tempfile::tempdir().unwrap();
let untracked = untracked_files(tmp_dir.path()).unwrap();
assert!(untracked.is_empty());

std::process::Command::new("git")
.arg("init")
.current_dir(tmp_dir.path())
.output()
.expect("failed to run git init");

std::fs::write(tmp_dir.path().join("test.txt"), "test").unwrap();
let untracked = untracked_files(tmp_dir.path()).unwrap();
assert!(untracked.len() == 1);
let expected = tmp_dir.path().join("test.txt");
assert!(untracked[0] == expected);
}

#[test]
fn test_untracked_files_with_spaces_and_parens() {
let tmp_dir = tempfile::tempdir().unwrap();

std::process::Command::new("git")
.arg("init")
.current_dir(tmp_dir.path())
.output()
.expect("failed to run git init");

// Nested dirs with spaces and parentheses
let d1 = tmp_dir.path().join("dir with spaces");
let d2 = d1.join("(special)");
std::fs::create_dir_all(&d2).unwrap();

let f1 = d1.join("file (1).txt");
let f2 = d2.join("a b (2).rb");
std::fs::write(&f1, "one").unwrap();
std::fs::write(&f2, "two").unwrap();

let mut untracked = untracked_files(tmp_dir.path()).unwrap();
untracked.sort();

let mut expected = vec![f1, f2];
expected.sort();

assert_eq!(untracked, expected);
}

#[test]
fn test_untracked_files_multiple_files_order_insensitive() {
let tmp_dir = tempfile::tempdir().unwrap();

std::process::Command::new("git")
.arg("init")
.current_dir(tmp_dir.path())
.output()
.expect("failed to run git init");

let f1 = tmp_dir.path().join("a.txt");
let f2 = tmp_dir.path().join("b.txt");
let f3 = tmp_dir.path().join("c.txt");
std::fs::write(&f1, "A").unwrap();
std::fs::write(&f2, "B").unwrap();
std::fs::write(&f3, "C").unwrap();

let mut untracked = untracked_files(tmp_dir.path()).unwrap();
untracked.sort();

let mut expected = vec![f1, f2, f3];
expected.sort();

assert_eq!(untracked, expected);
}

#[test]
fn test_untracked_files_excludes_staged() {
let tmp_dir = tempfile::tempdir().unwrap();

std::process::Command::new("git")
.arg("init")
.current_dir(tmp_dir.path())
.output()
.expect("failed to run git init");

let staged = tmp_dir.path().join("staged.txt");
let unstaged = tmp_dir.path().join("unstaged.txt");
std::fs::write(&staged, "I will be staged").unwrap();
std::fs::write(&unstaged, "I remain untracked").unwrap();

// Stage one file
let add_status = std::process::Command::new("git")
.arg("add")
.arg("staged.txt")
.current_dir(tmp_dir.path())
.output()
.expect("failed to run git add");
assert!(
add_status.status.success(),
"git add failed: {}",
String::from_utf8_lossy(&add_status.stderr)
);

let mut untracked = untracked_files(tmp_dir.path()).unwrap();
untracked.sort();

let expected = vec![unstaged];
assert_eq!(untracked, expected);
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod cache;
pub(crate) mod common_test;
pub mod config;
pub mod crosscheck;
pub(crate) mod files;
pub mod ownership;
pub(crate) mod project;
pub mod project_builder;
Expand Down
1 change: 1 addition & 0 deletions src/ownership/for_file_fast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ mod tests {
vendored_gems_path: vendored_path.to_string(),
cache_directory: "tmp/cache/codeowners".to_string(),
ignore_dirs: vec![],
skip_untracked_files: false,
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/project_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use tracing::{instrument, warn};
use crate::{
cache::Cache,
config::Config,
files,
project::{DirectoryCodeownersFile, Error, Package, PackageType, Project, ProjectFile, Team, VendoredGem, deserializers},
project_file_builder::ProjectFileBuilder,
};
Expand Down Expand Up @@ -56,13 +57,22 @@ impl<'a> ProjectBuilder<'a> {
let mut builder = WalkBuilder::new(&self.base_path);
builder.hidden(false);
builder.follow_links(false);

// Prune traversal early: skip heavy and irrelevant directories
let ignore_dirs = self.config.ignore_dirs.clone();
let base_path = self.base_path.clone();
let untracked_files = if self.config.skip_untracked_files {
files::untracked_files(&base_path).unwrap_or_default()
} else {
vec![]
};

builder.filter_entry(move |entry: &DirEntry| {
let path = entry.path();
let file_name = entry.file_name().to_str().unwrap_or("");
if !untracked_files.is_empty() && untracked_files.contains(&path.to_path_buf()) {
return false;
}
if let Some(ft) = entry.file_type()
&& ft.is_dir()
&& let Ok(rel) = path.strip_prefix(&base_path)
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/invalid_project/config/code_ownership.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ team_file_glob:
- config/teams/**/*.yml
vendored_gems_path: gems
unowned_globs:
skip_untracked_files: false
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ javascript_package_paths:
vendored_gems_path: gems
team_file_glob:
- config/teams/**/*.yml
skip_untracked_files: false
1 change: 1 addition & 0 deletions tests/fixtures/valid_project/config/code_ownership.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ team_file_glob:
- config/teams/**/*.yml
unbuilt_gems_path: gems
unowned_globs:
skip_untracked_files: false
41 changes: 41 additions & 0 deletions tests/untracked_files_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use assert_cmd::prelude::*;
use std::{error::Error, fs, path::Path, process::Command};

mod common;
use common::setup_fixture_repo;

const FIXTURE: &str = "tests/fixtures/invalid_project";

#[test]
fn test_skip_untracked_files() -> Result<(), Box<dyn Error>> {
// Arrange: copy fixture to temp dir and change a single CODEOWNERS mapping
let temp_dir = setup_fixture_repo(Path::new(FIXTURE));
let project_root = temp_dir.path();

// Act + Assert
Command::cargo_bin("codeowners")?
.arg("--project-root")
.arg(project_root)
.arg("--no-cache")
.arg("gv")
.assert()
.failure();

// Add skip_untracked_false: false to project_root/config/code_ownership.yml
let config_path = project_root.join("config/code_ownership.yml");
let original = fs::read_to_string(&config_path)?;
// Change payroll.rb ownership from @PayrollTeam to @PaymentsTeam to induce a mismatch
let modified = original.replace("skip_untracked_files: false", "skip_untracked_files: true");
fs::write(&config_path, modified)?;

// should succeed if skip_untracked_false is false
Command::cargo_bin("codeowners")?
.arg("--project-root")
.arg(project_root)
.arg("--no-cache")
.arg("gv")
.assert()
.success();

Ok(())
}