Skip to content
Open
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.5.0"
version = "0.6.0"
edition = "2024"

[profile.release]
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ cargo install --git https://github.com/rubyatscale/codeowners-rs codeowners
js_package_paths: []
unowned_globs:
- db/**/*
- app/services/some_file1.rb
- frontend/javascripts/**/__generated__/**/*
allow_unowned_globs:
- app/services/some_file1.rb
```

2. **Declare Teams**
Expand Down Expand Up @@ -209,11 +210,12 @@ codeowners gv --no-cache
- `ruby_package_paths` (default: `['packs/**/*', 'components/**']`)
- `js_package_paths` / `javascript_package_paths` (default: `['frontend/**/*']`)
- `team_file_glob` (default: `['config/teams/**/*.yml']`)
- `unowned_globs` (default: `['frontend/**/node_modules/**/*', 'frontend/**/__generated__/**/*']`)
- `unowned_globs` (default: `['frontend/**/node_modules/**/*', 'frontend/**/__generated__/**/*']`): Files matched here are left out of the project: they don't need an owner, validation doesn't check them, and file annotations in them are ignored. Use this for third-party or generated code.
- `vendored_gems_path` (default: `'vendored/'`)
- `cache_directory` (default: `'tmp/cache/codeowners'`)
- `ignore_dirs` (default includes: `.git`, `node_modules`, `tmp`, etc.)
- `executable_name` (default: `'codeowners'`): Customize the command name shown in validation error messages. Useful when using `codeowners-rs` via wrappers like the [code_ownership](https://github.com/rubyatscale/code_ownership) Ruby gem.
- `allow_unowned_globs` (default: `[]`): Files in `owned_globs` that match these globs don't need an owner, so leaving them unowned isn't a validation error. Unlike `unowned_globs`, file annotations in them still count, and every other check still runs, so a file there can't have more than one owner or name an invalid team. Use `['**/*']` if only some of your code has owners, or scope it, e.g. `['**/deprecated/**/*']`. `unowned_globs` takes precedence, so a file that matches both is left out. If you use `unowned_globs: ['**/*']` only so that files can go unowned, replace it with `allow_unowned_globs: ['**/*']` to make annotations work. Those files then get the other checks too, which may report problems that were hidden before.

Example configuration with custom executable name:

Expand All @@ -238,7 +240,7 @@ By default, cache is stored under `tmp/cache/codeowners` relative to the project

1. Only one mechanism defines ownership for any file.
2. All referenced teams are valid.
3. All files in `owned_globs` are owned, unless matched by `unowned_globs`.
3. All files in `owned_globs` are owned, unless matched by `unowned_globs` or `allow_unowned_globs`.
4. The generated `CODEOWNERS` file is up to date.

Exit status is non-zero on errors.
Expand Down
22 changes: 22 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ pub struct Config {

#[serde(default = "default_codeowners_path")]
pub codeowners_path: String,

#[serde(default)]
pub allow_unowned_globs: Vec<String>,
}

#[allow(dead_code)]
Expand Down Expand Up @@ -168,6 +171,25 @@ mod tests {
let config_file = File::open(&config_path)?;
let config: Config = serde_yaml::from_reader(config_file)?;
assert_eq!(config.executable_name, "codeowners generate");
assert!(config.allow_unowned_globs.is_empty());
Ok(())
}

#[test]
fn test_parse_config_with_allow_unowned_globs() -> Result<(), Box<dyn Error>> {
let temp_dir = tempdir()?;
let config_path = temp_dir.path().join("config.yml");
let config_str = indoc! {"
---
owned_globs:
- \"**/*.rb\"
allow_unowned_globs:
- \"**/deprecated/**/*\"
"};
fs::write(&config_path, config_str)?;
let config_file = File::open(&config_path)?;
let config: Config = serde_yaml::from_reader(config_file)?;
assert_eq!(config.allow_unowned_globs, vec!["**/deprecated/**/*"]);
Ok(())
}

Expand Down
1 change: 1 addition & 0 deletions src/ownership/file_owner_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ mod tests {
ignore_dirs: vec![],
executable_name: "codeowners".to_string(),
codeowners_path: ".github".to_string(),
allow_unowned_globs: vec![],
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/ownership/validator.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::path_utils::matches_globs;
use crate::project::{Project, ProjectFile};
use core::fmt;
use std::collections::HashSet;
Expand Down Expand Up @@ -172,7 +173,9 @@ impl Validator {
let relative_path = self.project.relative_path(&file.path).to_owned();

if owners.is_empty() {
validation_errors.push(Error::FileWithoutOwner { path: relative_path })
if !matches_globs(&relative_path, &self.project.allow_unowned_globs) {
validation_errors.push(Error::FileWithoutOwner { path: relative_path })
}
} else if owners.len() > 1 {
validation_errors.push(Error::FileWithMultipleOwners {
path: relative_path,
Expand Down
15 changes: 15 additions & 0 deletions src/path_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,25 @@ pub fn relative_to_buf(root: &Path, path: &Path) -> PathBuf {
relative_to(root, path).to_path_buf()
}

/// Returns true if `path` matches any of the provided glob patterns.
pub(crate) fn matches_globs(path: &Path, globs: &[String]) -> bool {
match path.to_str() {
Some(s) => globs.iter().any(|glob| fast_glob::glob_match(glob, s)),
None => false,
}
}

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

#[test]
fn matches_globs_double_star_matches_root_level_and_nested_files() {
let globs = vec!["**/*".to_string()];
assert!(matches_globs(Path::new("root.rb"), &globs));
assert!(matches_globs(Path::new("app/models/user.rb"), &globs));
}

#[test]
fn relative_to_returns_relative_when_under_root() {
let root = Path::new("/a/b");
Expand Down
2 changes: 2 additions & 0 deletions src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub struct Project {
pub directory_codeowner_files: Vec<DirectoryCodeownersFile>,
pub teams_by_name: HashMap<String, Team>,
pub executable_name: String,
pub allow_unowned_globs: Vec<String>,
}

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -222,6 +223,7 @@ mod tests {
directory_codeowner_files: vec![],
teams_by_name: HashMap::new(),
executable_name: "codeowners generate".to_string(),
allow_unowned_globs: vec![],
};

let map = project.vendored_gem_by_name();
Expand Down
11 changes: 3 additions & 8 deletions src/project_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ use std::{
};

use error_stack::{Report, ResultExt};
use fast_glob::glob_match;
use ignore::{DirEntry, WalkBuilder, WalkParallel, WalkState};
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use tracing::instrument;

use crate::{
cache::Cache,
config::Config,
path_utils::matches_globs,
project::{DirectoryCodeownersFile, Error, Package, PackageType, Project, ProjectFile, Team, VendoredGem, deserializers},
project_file_builder::ProjectFileBuilder,
tracked_files,
Expand Down Expand Up @@ -325,17 +325,11 @@ impl<'a> ProjectBuilder<'a> {
directory_codeowner_files: directory_codeowners,
teams_by_name,
executable_name: self.config.executable_name.clone(),
allow_unowned_globs: self.config.allow_unowned_globs.clone(),
})
}
}

fn matches_globs(path: &Path, globs: &[String]) -> bool {
match path.to_str() {
Some(s) => globs.iter().any(|glob| glob_match(glob, s)),
None => false,
}
}

pub(crate) fn ruby_package_owner(path: &Path) -> Result<Option<String>, Report<Error>> {
let file = File::open(path).change_context(Error::Io)?;
let deserializer: deserializers::RubyPackage = serde_yaml::from_reader(file).change_context(Error::SerdeYaml)?;
Expand Down Expand Up @@ -365,6 +359,7 @@ fn javascript_package_owner(path: &Path) -> Result<Option<String>, Report<Error>
#[cfg(test)]
mod tests {
use super::*;
use fast_glob::glob_match;

const OWNED_GLOB: &str = "{app,components,config,frontend,lib,packs,spec,danger,script}/**/*.{rb,arb,erb,rake,js,jsx,ts,tsx}";

Expand Down
31 changes: 10 additions & 21 deletions src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ use std::path::{Path, PathBuf};
use std::process::Command;

use error_stack::{Report, ResultExt};
use fast_glob::glob_match;
use serde::Serialize;
use tracing::debug_span;

use crate::{
cache::{Cache, Caching, file::GlobalCache, noop::NoopCache},
config::Config,
ownership::{FileOwner, Ownership},
path_utils::{matches_globs, relative_to_buf},
project_builder::ProjectBuilder,
};

Expand Down Expand Up @@ -148,26 +148,23 @@ impl Runner {

// Filter files based on owned_globs and unowned_globs configuration
// Only validate files that match owned_globs and don't match unowned_globs
let filtered_paths: Vec<String> = file_paths
let filtered_paths: Vec<(String, PathBuf)> = file_paths
.into_iter()
.filter(|file_path| {
// Convert to relative path for glob matching
let path = Path::new(file_path);
let relative_path = if path.is_absolute() {
path.strip_prefix(&self.run_config.project_root).unwrap_or(path)
} else {
path
};

// Mirror the filtering applied by ProjectBuilder when walking the project
.map(|file_path| {
let relative_path = relative_to_buf(&self.run_config.project_root, Path::new(&file_path));
(file_path, relative_path)
})
// Mirror the filtering applied by ProjectBuilder when walking the project
.filter(|(_, relative_path)| {
matches_globs(relative_path, &self.config.owned_globs) && !matches_globs(relative_path, &self.config.unowned_globs)
})
.collect();

debug_span!("per_file_query").in_scope(|| {
for file_path in filtered_paths {
for (file_path, relative_path) in filtered_paths {
match team_for_file_from_codeowners(&self.run_config, &file_path) {
Ok(Some(_)) => {}
Ok(None) if matches_globs(&relative_path, &self.config.allow_unowned_globs) => {}
Ok(None) => unowned_files.push(file_path),
Err(err) => io_errors.push(format!("{}: {}", file_path, err)),
}
Expand Down Expand Up @@ -442,14 +439,6 @@ impl RunResult {
}
}

/// Returns true if `path` matches any of the provided glob patterns.
fn matches_globs(path: &Path, globs: &[String]) -> bool {
match path.to_str() {
Some(s) => globs.iter().any(|glob| glob_match(glob, s)),
None => false,
}
}

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