Skip to content
Open
20 changes: 20 additions & 0 deletions crates/prek/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,26 @@ pub(crate) struct RunArgs {
#[command(flatten)]
pub(crate) options: RunOptions,

/// Select hooks by their configured repository.
///
/// Accepts `local`, `meta`, `builtin`, a complete remote repository URL, or
/// GitHub shorthand in `OWNER/REPOSITORY` form. As selectors, GitHub HTTPS and
/// SSH clone forms and shorthand are equivalent, with an optional `.git` suffix.
/// GitHub URL schemes, hostnames, owners, and repository names match
/// case-insensitively. Other configured values, including relative repository
/// paths, still match exactly.
/// Remote matching ignores `rev`. Can be specified multiple times; a repository
/// may match any specified value. In workspace mode, this applies across
/// discovered projects that remain after project selection and composes with
/// other hook, stage, and file filters.
#[arg(
long,
value_name = "REPO",
value_hint = ValueHint::Other,
help_heading = "Hook selection"
)]
pub(crate) repo: Vec<String>,

/// The stage during which the hook is fired.
///
/// When specified, only hooks configured for that stage (for example `manual`,
Expand Down
2 changes: 1 addition & 1 deletion crates/prek/src/cli/run/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ pub(crate) use filter::{
pub(crate) use install::{InstallCache, install_hooks};
pub(crate) use reporter::{HookRunReporter, project_status_marker};
pub(crate) use run::{HideStatus, run};
pub(crate) use selector::{ConfiguredHook, GroupFilters, SelectorSource, Selectors};
pub(crate) use selector::{ConfiguredHook, GroupFilters, RepoFilter, SelectorSource, Selectors};

mod diff;
mod filter;
Expand Down
11 changes: 9 additions & 2 deletions crates/prek/src/cli/run/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ use crate::cli::run::install::{InstallCache, install_hooks};
use crate::cli::run::keeper::WorkTreeKeeper;
use crate::cli::run::{
CollectOptions, FileSelection, FileTagCache, GroupFilters, HookFileFilter, HookRunReporter,
ProjectFiles, RunFileIndex, RunInput, Selectors, collect_run_input, project_status_marker,
ProjectFiles, RepoFilter, RunFileIndex, RunInput, Selectors, collect_run_input,
project_status_marker,
};
use crate::cli::{ExitStatus, RunArgs, RunExtraArgs, RunOptions, flag};
use crate::config::{PassFilenames, Stage};
Expand Down Expand Up @@ -84,6 +85,7 @@ pub(crate) async fn run(
) -> Result<ExitStatus> {
let RunArgs {
options,
repo,
stage: hook_stage,
groups,
required_groups,
Expand Down Expand Up @@ -125,6 +127,7 @@ pub(crate) async fn run(
let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?;
let selectors = Selectors::load(&includes, &skips, &workspace_root)?;
let group_filters = GroupFilters::parse(&groups, &required_groups, &no_groups)?;
let repo_filters = repo.into_iter().map(RepoFilter::new).collect::<Vec<_>>();
let has_group_filters = group_filters.has_filters();
let workspace = Workspace::discover(store, workspace_root, config, Some(&selectors), refresh)?;

Expand All @@ -140,7 +143,8 @@ pub(crate) async fn run(
workspace
.init_hooks(
store,
HookInitFilters::new(Some(&selectors), Some(&group_filters)),
HookInitFilters::new(Some(&selectors), Some(&group_filters))
.with_repo_filter(&repo_filters),
Some(&reporter),
)
.await
Expand All @@ -160,6 +164,9 @@ pub(crate) async fn run(

selectors.report_unused();
group_filters.report_unused();
for repo_filter in &repo_filters {
repo_filter.report_unused();
}

if selected_hooks.is_empty() {
writeln!(
Expand Down
254 changes: 253 additions & 1 deletion crates/prek/src/cli/run/selector.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use std::borrow::Cow;
use std::fmt::Display;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};

use crate::config::validate_group_name;
use crate::config::{self, validate_group_name};
use crate::hook::Hook;
use crate::warn_user;

Expand Down Expand Up @@ -185,6 +186,125 @@ impl Selector {
}
}

#[derive(Debug)]
pub(crate) struct RepoFilter {
original: String,
github_repo: Option<GitHubRepo>,
matched: AtomicBool,
}

#[derive(Debug)]
struct GitHubRepo {
owner: String,
repository: String,
}

impl GitHubRepo {
fn new(owner: &str, repository: &str) -> Self {
Self {
owner: owner.to_ascii_lowercase(),
repository: repository.to_ascii_lowercase(),
}
}

fn matches(&self, owner: &str, repository: &str) -> bool {
self.owner.eq_ignore_ascii_case(owner) && self.repository.eq_ignore_ascii_case(repository)
}
}

fn parse_github_path(path: &str) -> Option<(&str, &str)> {
let mut components = path.split('/');
let (Some(owner), Some(repository), None) =
(components.next(), components.next(), components.next())
else {
return None;
};
let repository = repository.strip_suffix(".git").unwrap_or(repository);
if owner.is_empty() || repository.is_empty() {
return None;
}

Some((owner, repository))
}

fn parse_github_url(value: &str) -> Option<(&str, &str)> {
let (host, path) = match value.split_once("://") {
Some((scheme, rest)) if scheme.eq_ignore_ascii_case("https") => rest.split_once('/')?,
Some((scheme, rest)) if scheme.eq_ignore_ascii_case("ssh") => {
rest.strip_prefix("git@")?.split_once('/')?
}
Some(_) => return None,
None => value.strip_prefix("git@")?.split_once(':')?,
};
if !host.eq_ignore_ascii_case("github.com") {
return None;
}

parse_github_path(path)
}

fn parse_github_selector(value: &str) -> Option<(&str, &str)> {
if let Some(repo) = parse_github_url(value) {
return Some(repo);
}

parse_github_path(value)
}

impl RepoFilter {
pub(crate) fn new(original: String) -> Self {
let github_repo = if let Some((owner, repository)) = parse_github_selector(&original) {
Some(GitHubRepo::new(owner, repository))
} else {
None
};

Self {
original,
github_repo,
matched: AtomicBool::new(false),
}
}

fn matches_remote_repo(&self, repo: &config::RemoteRepo) -> bool {
if repo.repo() == self.original {
return true;
}

let Some(github_repo) = &self.github_repo else {
return false;
};
let Some((owner, repository)) = parse_github_url(repo.repo()) else {
return false;
};

github_repo.matches(owner, repository)
}

pub(crate) fn matches_repo(&self, repo: &config::Repo) -> bool {
let matches = match repo {
config::Repo::Local(_) => self.original == "local",
config::Repo::Meta(_) => self.original == "meta",
config::Repo::Builtin(_) => self.original == "builtin",
config::Repo::Remote(repo) => self.matches_remote_repo(repo),
};
Comment thread
pygarap marked this conversation as resolved.

if matches {
self.matched.store(true, Ordering::Relaxed);
}
matches
}

pub(crate) fn report_unused(&self) {
if !self.matched.load(Ordering::Relaxed) {
warn_user!(
"repository selector `--repo={}` did not match any configured repositories",
self.original
);
}
}
}

#[derive(Debug, Clone, Default)]
pub(crate) struct Selectors {
includes: Vec<Selector>,
Expand Down Expand Up @@ -889,6 +1009,138 @@ mod tests {
})
}

#[test]
fn repo_filter_matches_configured_remote_value_and_github_shorthand() {
const GITHUB_REPO: &str = "https://github.com/OWNER/REPOSITORY";
const GITHUB_REPO_DOT_GIT: &str = "https://github.com/OWNER/REPOSITORY.git";
const GITHUB_SSH_REPO: &str = "git@github.com:OWNER/REPOSITORY";
const GITHUB_SSH_REPO_DOT_GIT: &str = "git@github.com:OWNER/REPOSITORY.git";
const GITHUB_SSH_URL: &str = "ssh://git@github.com/OWNER/REPOSITORY";
const GITHUB_SSH_URL_DOT_GIT: &str = "ssh://git@github.com/OWNER/REPOSITORY.git";
const GITHUB_SHORTHAND: &str = "OWNER/REPOSITORY";
const GITHUB_CONFIGURED_URLS: [&str; 6] = [
GITHUB_REPO,
GITHUB_REPO_DOT_GIT,
GITHUB_SSH_REPO,
GITHUB_SSH_REPO_DOT_GIT,
GITHUB_SSH_URL,
GITHUB_SSH_URL_DOT_GIT,
];
const GITHUB_SELECTORS: [&str; 7] = [
GITHUB_REPO,
GITHUB_REPO_DOT_GIT,
GITHUB_SSH_REPO,
GITHUB_SSH_REPO_DOT_GIT,
GITHUB_SSH_URL,
GITHUB_SSH_URL_DOT_GIT,
GITHUB_SHORTHAND,
];

for configured in GITHUB_CONFIGURED_URLS {
let remote =
config::RemoteRepo::new(configured.to_string(), "v1.0.0".to_string(), Vec::new());
for selector in GITHUB_SELECTORS {
assert!(
RepoFilter::new(selector.to_string())
.matches_repo(&config::Repo::Remote(remote.clone()))
);
}
}

let mut remote =
config::RemoteRepo::new(GITHUB_REPO.to_string(), "v1.0.0".to_string(), Vec::new());
for selector in ["OWNER/", "/REPOSITORY"] {
assert!(
!RepoFilter::new(selector.to_string())
.matches_repo(&config::Repo::Remote(remote.clone()))
);
}
assert!(
!RepoFilter::new("https://github.com/OTHER/REPOSITORY".to_string())
.matches_repo(&config::Repo::Remote(remote.clone()))
);

remote.rev = "different-revision".to_string();
remote.set_resolved_source("https://mirror.example.com/OWNER/REPOSITORY".to_string());
assert!(
RepoFilter::new(GITHUB_SHORTHAND.to_string())
.matches_repo(&config::Repo::Remote(remote.clone()))
);
assert!(
!RepoFilter::new("https://mirror.example.com/OWNER/REPOSITORY".to_string())
.matches_repo(&config::Repo::Remote(remote))
);

const GITLAB_URL: &str = "https://gitlab.com/OWNER/REPOSITORY.git";
const GITLAB_SSH_REPO: &str = "git@gitlab.com:OWNER/REPOSITORY.git";
for configured in [GITLAB_URL, GITLAB_SSH_REPO] {
let remote =
config::RemoteRepo::new(configured.to_string(), "v2.0.0".to_string(), Vec::new());
assert!(
RepoFilter::new(configured.to_string())
.matches_repo(&config::Repo::Remote(remote.clone()))
);
assert!(
!RepoFilter::new("OWNER/REPOSITORY".to_string())
.matches_repo(&config::Repo::Remote(remote.clone()))
);
assert!(
!RepoFilter::new(configured.to_ascii_lowercase())
.matches_repo(&config::Repo::Remote(remote))
);
}
}

#[test]
fn repo_filter_matches_github_components_case_insensitively() {
const GITHUB_CONFIGURED_URLS: [&str; 3] = [
"HTTPS://GitHub.com/Pre-Commit/Pre-Commit-Hooks.git",
"git@GitHub.com:Pre-Commit/Pre-Commit-Hooks.git",
"SSH://git@GitHub.com/Pre-Commit/Pre-Commit-Hooks.git",
];
const GITHUB_SELECTORS: [&str; 4] = [
"pre-commit/pre-commit-hooks",
"https://github.com/pre-commit/pre-commit-hooks",
"git@github.com:pre-commit/pre-commit-hooks.git",
"ssh://git@github.com/pre-commit/pre-commit-hooks.git",
];

for configured in GITHUB_CONFIGURED_URLS {
let remote =
config::RemoteRepo::new(configured.to_string(), "v1.0.0".to_string(), Vec::new());
for selector in GITHUB_SELECTORS {
assert!(
RepoFilter::new(selector.to_string())
.matches_repo(&config::Repo::Remote(remote.clone()))
);
}
}
}

#[test]
fn repo_filter_does_not_canonicalize_configured_relative_repo() {
const RELATIVE_REPO: &str = "vendor/hooks";
let mut remote =
config::RemoteRepo::new(RELATIVE_REPO.to_string(), "v3.0.0".to_string(), Vec::new());
remote.set_resolved_source("/workspace/vendor/hooks".to_string());
assert!(
RepoFilter::new(RELATIVE_REPO.to_string())
.matches_repo(&config::Repo::Remote(remote.clone()))
);
assert!(
!RepoFilter::new("https://github.com/vendor/hooks".to_string())
.matches_repo(&config::Repo::Remote(remote.clone()))
);
assert!(
!RepoFilter::new("git@github.com:vendor/hooks.git".to_string())
.matches_repo(&config::Repo::Remote(remote.clone()))
);
assert!(
!RepoFilter::new("VENDOR/HOOKS".to_string())
.matches_repo(&config::Repo::Remote(remote))
);
}

#[test]
fn test_parse_single_selector_hook_id() -> anyhow::Result<()> {
let fs = create_test_workspace()?;
Expand Down
Loading
Loading