Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
93b6d92
Add allow_unowned_files to permit files with no owner
dduugg Sep 28, 2026
0b91d29
Take globs in allow_unowned_globs rather than a boolean
dduugg Sep 28, 2026
2060252
Keep glob matching local to the validator and reuse relative_to_buf
dduugg Sep 28, 2026
b694dda
Share one matches_globs in path_utils
dduugg Sep 28, 2026
9edb0ef
Test allow_unowned_globs with absolute paths and the checks it keeps
dduugg Sep 28, 2026
31cf459
Explain how allow_unowned_globs differs from unowned_globs
dduugg Sep 28, 2026
62ed8d7
Bump version to 0.6.0
dduugg Sep 28, 2026
1f83550
Merge main into allow-unowned-files
dduugg Oct 21, 2026
efc40f5
Read allow_unowned_globs with strings_or_nulls
dduugg Oct 21, 2026
5b28431
Report allowed files that name an owner in validate <files>
dduugg Oct 21, 2026
56c6a0d
Test that unowned_globs takes precedence over allow_unowned_globs
dduugg Oct 21, 2026
ded8a7f
Count a package naming an unknown team in validate <files>
dduugg Oct 22, 2026
351b804
Test more of how validate <files> treats allowed files
dduugg Oct 22, 2026
f15fff0
Say what unowned_globs still reads
dduugg Oct 22, 2026
013fdd5
Make only absolute paths project-relative in validate <files>
dduugg Oct 22, 2026
d1f7de3
Excuse only a plain project-relative path in validate <files>
dduugg Oct 22, 2026
d4a6405
Match a package naming an unknown team by its directory
dduugg Oct 22, 2026
2abedac
Say the owner lookup sees only files the project reads
dduugg Oct 22, 2026
a28ea87
Check allow_unowned_globs in parallel in validate
dduugg Oct 22, 2026
2fb71cd
Share fixture setup between the allow_unowned_globs tests
dduugg Oct 22, 2026
f7d1d52
Name the allow_unowned_globs tests after what they check
dduugg Oct 22, 2026
ce601e2
Test more owner sources for allowed files
dduugg Oct 22, 2026
0fef8a1
Keep unit tests next to what they test
dduugg Oct 22, 2026
f314fd8
List allow_unowned_globs next to unowned_globs in the README
dduugg Oct 22, 2026
9b28647
Explain allow_unowned_globs for file lists, migration and old versions
dduugg Oct 22, 2026
6325119
Match a .codeowner naming an unknown team by its directory too
dduugg Oct 22, 2026
27c7579
Report allowed files under their own heading in validate <files>
dduugg Oct 22, 2026
b4188fb
Say the config file is held to the default node limit
dduugg Oct 22, 2026
f40c570
Say that unowned_globs still reads team files
dduugg Oct 22, 2026
63e878a
Match a .codeowner naming an unknown team only by its directory
dduugg Oct 22, 2026
be2b436
Look up a non-plain allowed path in CODEOWNERS by its plain form
dduugg Oct 22, 2026
5c14495
Don't suggest updating CODEOWNERS that gv <files> has just written
dduugg Oct 22, 2026
b7d17c2
Test a package.json owner and a sibling directory for allowed files
dduugg Oct 22, 2026
1030e89
Share config writing between the allow_unowned_globs tests
dduugg Oct 22, 2026
c11f489
Correct comments on the owner lookup and the config's node limit
dduugg Oct 22, 2026
579a86d
Clarify the README on team files and what the owner lookup reads
dduugg Oct 22, 2026
60e7ef9
Give executable_name's real default in the README
dduugg Oct 22, 2026
132bde1
Rename allow_unowned_globs to optionally_owned_globs
dduugg Oct 22, 2026
8f76d20
Stop at the first owner in OwnerLookup::has_owner
dduugg Oct 22, 2026
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"
license = "MIT"

Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,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__/**/*
optionally_owned_globs:
- app/services/some_file1.rb
```

2. **Declare Teams**
Expand Down Expand Up @@ -211,19 +212,20 @@ 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. A `.codeowner`, `package.yml` or `package.json` among them is still read, though, so its owner still goes into CODEOWNERS and an invalid team in it is still reported. A team file among them is still read too, so it still defines its team and keeps its own line in CODEOWNERS. Use this for third-party or generated code.
- `optionally_owned_globs` (default: `[]`, added in 0.6.0): Files in `owned_globs` that match these globs don't need an owner. It doesn't add to `owned_globs`, so a file must match both. Unlike `unowned_globs`, file annotations in them still count, and a full `validate` or `gv` still runs every other check, so a file there can't have more than one owner or name an invalid team. Given a list of files, as in a git hook, `validate` and `gv` look each one up in CODEOWNERS and don't check for more than one owner. An optionally owned file with no line passes only if nothing names an owner for it, so one whose annotation, `.codeowner` or package names a team that doesn't exist, or whose owner CODEOWNERS doesn't list yet, is reported under "Files matching optionally_owned_globs name an owner that CODEOWNERS doesn't list". As in a full `validate`, an annotation, `.codeowner` or package file doesn't count if the file is untracked or in a top-level directory named in `ignore_dirs`, so a new file's own annotation counts once the file is staged. 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 `optionally_owned_globs: ['**/*']` to make annotations work, and run `codeowners gv` to add a CODEOWNERS line for each annotated file. The files that `unowned_globs` left out are then read and checked like any other owned file, which takes longer and may turn up problems that were hidden before. Keep third-party and generated code in `unowned_globs`. Versions before 0.6.0 ignore the key without a warning, so they still report unowned files in these globs.
- `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.
- `yaml_max_nodes` (default: `20000`): The most YAML nodes (mappings, sequences and scalars) allowed in one team file or `package.yml`. A file over it, or over the limits on total scalar bytes and anchor copies that grow with it, fails with a `budget breached` error and a hint to raise this key in the config file in use. Raise it if a large team file hits the limit; real files have at most a few thousand nodes. It does not apply to the config file itself. Raising it also raises how much memory a single file can take: a file crafted to waste memory can make `codeowners` use about 280 MB more at the default and about 700 MB more at 50,000, measured on macOS, while ordinary files add almost nothing. Separately, any of these files, or the config, fails if it nests more than 16 levels deep, counting the top level. That limit can't be raised.
- `executable_name` (default: `'codeowners generate'`): The command that validation errors tell you to run to update the CODEOWNERS file. Useful when using `codeowners-rs` via wrappers like the [code_ownership](https://github.com/rubyatscale/code_ownership) Ruby gem.
- `yaml_max_nodes` (default: `20000`): The most YAML nodes (mappings, sequences and scalars) allowed in one team file or `package.yml`. A file over it, or over the limits on total scalar bytes and anchor copies that grow with it, fails with a `budget breached` error and a hint to raise this key in the config file in use. Raise it if a large team file hits the limit; real files have at most a few thousand nodes. It does not apply to the config file itself, which is always held to the default of 20,000 nodes, about one per list entry. Raising it also raises how much memory a single file can take: a file crafted to waste memory can make `codeowners` use about 280 MB more at the default and about 700 MB more at 50,000, measured on macOS, while ordinary files add almost nothing. Separately, any of these files, or the config, fails if it nests more than 16 levels deep, counting the top level. That limit can't be raised.

Example configuration with custom executable name:

```yaml
owned_globs:
- '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}'
executable_name: 'bin/codeownership' # For Ruby gem wrapper
executable_name: 'bin/codeownership validate' # For Ruby gem wrapper
```

See examples in `tests/fixtures/**/config/` for reference setups.
Expand All @@ -241,7 +243,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 `optionally_owned_globs`.
4. The generated `CODEOWNERS` file is up to date.

Exit status is non-zero on errors.
Expand Down
26 changes: 25 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ pub struct Config {
#[serde(default = "default_codeowners_path", deserialize_with = "crate::yaml::string_or_null")]
pub codeowners_path: String,

#[serde(default, deserialize_with = "crate::yaml::strings_or_nulls")]
pub optionally_owned_globs: Vec<String>,

#[serde(default = "default_yaml_max_nodes")]
pub yaml_max_nodes: usize,

Expand Down Expand Up @@ -122,7 +125,7 @@ fn default_path() -> PathBuf {
impl Config {
pub fn load_from_path(path: &Path) -> std::result::Result<Self, String> {
let bytes = std::fs::read(path).map_err(|e| format!("Can't open config file: {} ({})", path.to_string_lossy(), e))?;
// The config can't raise its own limit, and at a few hundred nodes it never needs to.
// The config can't raise its own limit, so its lists can hold about 20,000 entries in all.
let config: Self = crate::yaml::from_slice(&bytes, crate::yaml::DEFAULT_MAX_NODES)
.map_err(|e| format!("Can't parse config file: {} ({})", path.to_string_lossy(), e))?;
Ok(Self {
Expand Down Expand Up @@ -173,6 +176,7 @@ mod tests {
);
assert_eq!(config.vendored_gems_path, "vendored/");
assert_eq!(config.executable_name, "codeowners generate");
assert!(config.optionally_owned_globs.is_empty());
assert_eq!(config.yaml_max_nodes, 20_000);
Ok(())
}
Expand Down Expand Up @@ -254,6 +258,23 @@ mod tests {
Ok(())
}

#[test]
fn test_parse_config_with_optionally_owned_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\"
optionally_owned_globs:
- \"**/deprecated/**/*\"
"};
fs::write(&config_path, config_str)?;
let config = Config::load_from_path(&config_path)?;
assert_eq!(config.optionally_owned_globs, vec!["**/deprecated/**/*"]);
Ok(())
}

#[test]
fn test_codeowners_path_defaults_when_not_specified() -> Result<(), Box<dyn Error>> {
let temp_dir = tempdir()?;
Expand Down Expand Up @@ -304,6 +325,8 @@ mod tests {
-
ignore_dirs:
-
optionally_owned_globs:
-
vendored_gems_path:
cache_directory:
executable_name:
Expand All @@ -318,6 +341,7 @@ mod tests {
&config.team_file_glob,
&config.unowned_globs,
&config.ignore_dirs,
&config.optionally_owned_globs,
] {
assert_eq!(globs, &[""]);
}
Expand Down
59 changes: 58 additions & 1 deletion src/ownership.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use mapper::{OwnerMatcher, Source, TeamName};
use std::{
error::Error,
fmt::{self, Display},
path::Path,
path::{Path, PathBuf},
sync::Arc,
};
use tracing::{info, instrument};
Expand Down Expand Up @@ -109,6 +109,27 @@ impl Entry {
}
}

/// Tells whether anything names an owner for a project-relative path, even a team that doesn't exist: an annotation naming one
/// still counts for its file, and a `.codeowner`, `package.yml` or `package.json` naming one for every path under its directory.
/// It sees only files the project reads, so an annotated file, `.codeowner` or package file names no owner here if it is
/// untracked or under a top-level directory named in ignore_dirs, as in `validate`. Building one goes through every project
/// file, so reuse it.
pub(crate) struct OwnerLookup {
owner_matchers: Vec<OwnerMatcher>,
unknown_team_roots: Vec<PathBuf>,
}

impl OwnerLookup {
pub(crate) fn has_owner(&self, relative_path: &Path) -> bool {
// FileOwnerFinder::find returns an owner exactly when some matcher names a team, but builds every owner to do it.
self.unknown_team_roots.iter().any(|root| relative_path.starts_with(root))
|| self
.owner_matchers
.iter()
.any(|owner_matcher| owner_matcher.owner_for(relative_path).0.is_some())
}
}

impl Ownership {
pub fn build(project: Project) -> Self {
Self {
Expand Down Expand Up @@ -156,6 +177,42 @@ impl Ownership {
.collect())
}

pub(crate) fn owner_lookup(&self) -> OwnerLookup {
// `validate` reports a package.yml, package.json or `.codeowner` naming a team that doesn't exist, but a list of files
// isn't checked for that, so here each still names an owner for the files under its directory. The package mappers drop
// such a package, and the directory mapper's glob misses a `.codeowner` at the project root and reads braces as alternatives,
// matching `app/legacy` for one in `app/{legacy}`. So the directory is matched as a path prefix, which replaces that glob
// for such a `.codeowner`: brackets and braces are literal and the root covers every file.
let package_roots = self
.project
.packages
.iter()
.filter(|package| !self.project.teams_by_name.contains_key(&package.owner))
.filter_map(|package| package.package_root());
let directory_roots = self
.project
.directory_codeowner_files
.iter()
.filter(|directory_codeowner_file| !self.project.teams_by_name.contains_key(&directory_codeowner_file.owner))
.filter_map(|directory_codeowner_file| directory_codeowner_file.directory_root());
OwnerLookup {
owner_matchers: self
.mappers()
.iter()
.flat_map(|mapper| mapper.owner_matchers())
.filter(|owner_matcher| match owner_matcher {
OwnerMatcher::Glob {
source: Source::Directory(_),
team_name,
..
} => self.project.teams_by_name.contains_key(team_name),
_ => true,
})
.collect(),
unknown_team_roots: package_roots.chain(directory_roots).map(Path::to_path_buf).collect(),
}
}

#[instrument(level = "debug", skip_all)]
pub fn for_team(&self, team_name: &str) -> Result<Vec<TeamOwnership>, Box<dyn Error>> {
info!("getting team ownership for {}", team_name);
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 @@ -385,6 +385,7 @@ mod tests {
ignore_dirs: vec![],
executable_name: "codeowners".to_string(),
codeowners_path: ".github".to_string(),
optionally_owned_globs: vec![],
yaml_max_nodes: crate::yaml::DEFAULT_MAX_NODES,
path: PathBuf::from(crate::config::DEFAULT_PATH),
}
Expand Down
41 changes: 25 additions & 16 deletions 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 All @@ -6,6 +7,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;

use itertools::Itertools;
use rayon::prelude::IntoParallelIterator;
use rayon::prelude::IntoParallelRefIterator;
use rayon::prelude::ParallelIterator;
use similar::{ChangeTag, TextDiff};
Expand Down Expand Up @@ -166,22 +168,29 @@ impl Validator {

#[instrument(name = "validate_file_ownership", level = "debug", skip_all)]
fn validate_file_ownership(&self) -> Vec<Error> {
let mut validation_errors = Vec::new();

for (file, owners) in self.file_to_owners() {
let relative_path = self.project.relative_path(&file.path).to_owned();

if owners.is_empty() {
validation_errors.push(Error::FileWithoutOwner { path: relative_path })
} else if owners.len() > 1 {
validation_errors.push(Error::FileWithMultipleOwners {
path: relative_path,
owners,
})
}
}

validation_errors
let project = &self.project;

self.file_to_owners()
.into_par_iter()
.filter_map(|(file, owners)| {
let relative_path = project.relative_path(&file.path).to_owned();

if owners.is_empty() {
if matches_globs(&relative_path, &project.optionally_owned_globs) {
None
} else {
Some(Error::FileWithoutOwner { path: relative_path })
}
} else if owners.len() > 1 {
Some(Error::FileWithMultipleOwners {
path: relative_path,
owners,
})
} else {
None
}
})
.collect()
}

#[instrument(name = "validate_codeowners_file", level = "debug", skip_all)]
Expand Down
66 changes: 65 additions & 1 deletion src/path_utils.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};

/// Return `path` relative to `root` if possible; otherwise return `path` unchanged.
pub fn relative_to<'a>(root: &'a Path, path: &'a Path) -> &'a Path {
Expand All @@ -10,10 +10,51 @@ pub fn relative_to_buf(root: &Path, path: &Path) -> PathBuf {
relative_to(root, path).to_path_buf()
}

/// `path` without `.` components or repeated separators, or `None` if it has a `..`, a root or a prefix. Owners are matched
/// against plain project-relative paths, and no other path can be made into one without asking the file system.
pub(crate) fn plain_relative(path: &Path) -> Option<PathBuf> {
let mut plain = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(name) => plain.push(name),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(plain)
}

/// 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::*;

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

#[test]
fn test_matches_globs() {
assert!(matches_globs(Path::new("script/.eslintrc.js"), &[OWNED_GLOB.to_string()]));
}

#[test]
fn test_glob_match() {
assert!(fast_glob::glob_match(OWNED_GLOB, "script/.eslintrc.js"));
}

#[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 Expand Up @@ -46,4 +87,27 @@ mod tests {
let rel_buf = relative_to_buf(root, path);
assert_eq!(rel_ref, rel_buf.as_path());
}

// Compared as strings, as globs see them, since Path equality ignores a repeated separator or a `.` past the start.
fn plain(path: &str) -> Option<String> {
plain_relative(Path::new(path)).map(|plain| plain.to_string_lossy().into_owned())
}

#[test]
fn plain_relative_returns_a_plain_path_unchanged() {
assert_eq!(plain("a/b").as_deref(), Some("a/b"));
}

#[test]
fn plain_relative_drops_dot_components_and_repeated_separators() {
assert_eq!(plain("./a/b").as_deref(), Some("a/b"));
assert_eq!(plain("a/./b").as_deref(), Some("a/b"));
assert_eq!(plain("a//b").as_deref(), Some("a/b"));
}

#[test]
fn plain_relative_returns_none_for_parent_dirs_and_roots() {
assert_eq!(plain("a/../b"), None);
assert_eq!(plain("/a/b"), None);
}
}
2 changes: 2 additions & 0 deletions src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub struct Project {
pub directory_codeowner_files: Vec<DirectoryCodeownersFile>,
pub teams_by_name: HashMap<String, Team>,
pub executable_name: String,
pub optionally_owned_globs: Vec<String>,
}

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

let map = project.vendored_gem_by_name();
Expand Down
Loading
Loading