Skip to content
Open
18 changes: 18 additions & 0 deletions crates/prek/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,24 @@ 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.
/// Configured 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
183 changes: 182 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,94 @@ impl Selector {
}
}

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

fn canonical_github_path(path: &str) -> Option<String> {
const GITHUB_URL_PREFIX: &str = "https://github.com/";

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;
}

Comment thread
pygarap marked this conversation as resolved.
Some(format!("{GITHUB_URL_PREFIX}{owner}/{repository}"))
Comment thread
pygarap marked this conversation as resolved.
Outdated
}

fn canonical_github_url(value: &str) -> Option<String> {
const GITHUB_URL_PREFIX: &str = "https://github.com/";
const GITHUB_SSH_URL_PREFIX: &str = "ssh://git@github.com/";
const GITHUB_SCP_PREFIX: &str = "git@github.com:";
Comment thread
pygarap marked this conversation as resolved.
Outdated

let path = if let Some(path) = value.strip_prefix(GITHUB_URL_PREFIX) {
path
} else if let Some(path) = value.strip_prefix(GITHUB_SSH_URL_PREFIX) {
path
} else {
value.strip_prefix(GITHUB_SCP_PREFIX)?
};

canonical_github_path(path)
}
Comment thread
pygarap marked this conversation as resolved.
Outdated

fn canonical_github_selector(value: &str) -> Option<String> {
if let Some(url) = canonical_github_url(value) {
return Some(url);
}

canonical_github_path(value)
}

impl RepoFilter {
pub(crate) fn new(original: String) -> Self {
let github_url = canonical_github_selector(&original);

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

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) => {
repo.repo() == self.original
|| self.github_url.as_deref().is_some_and(|github_url| {
canonical_github_url(repo.repo()).as_deref() == Some(github_url)
})
}
};
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 +978,98 @@ 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());
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))
);
}
}

#[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))
);
}

#[test]
fn test_parse_single_selector_hook_id() -> anyhow::Result<()> {
let fs = create_test_workspace()?;
Expand Down
26 changes: 25 additions & 1 deletion crates/prek/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::{debug, error, instrument, trace};

use crate::cli::run::{ConfiguredHook, GroupFilters, Selectors};
use crate::cli::run::{ConfiguredHook, GroupFilters, RepoFilter, Selectors};
use crate::config::{self, Config, read_config};
use crate::fs::Simplified;
use crate::git::GIT_ROOT;
Expand Down Expand Up @@ -59,6 +59,7 @@ pub(crate) trait HookInitReporter {
pub(crate) struct HookInitFilters<'a> {
selectors: Option<&'a Selectors>,
group_filters: Option<&'a GroupFilters>,
repo_filters: &'a [RepoFilter],
}

impl<'a> HookInitFilters<'a> {
Expand All @@ -69,13 +70,33 @@ impl<'a> HookInitFilters<'a> {
Self {
selectors,
group_filters,
repo_filters: &[],
}
}

pub(crate) fn with_repo_filter(mut self, repo_filters: &'a [RepoFilter]) -> Self {
self.repo_filters = repo_filters;
self
}

pub(crate) fn none() -> Self {
Self::default()
}

fn keeps_repo(self, repo: &config::Repo) -> bool {
if self.repo_filters.is_empty() {
return true;
}

let mut matches = false;
for repo_filter in self.repo_filters {
if repo_filter.matches_repo(repo) {
matches = true;
}
}
matches
}

fn keeps_remote_repo(self, project: &Project, repo: &config::RemoteRepo) -> bool {
Comment thread
pygarap marked this conversation as resolved.
Outdated
repo.hooks.iter().any(|hook| {
let hook = ConfiguredHook::new(
Expand Down Expand Up @@ -116,6 +137,9 @@ impl<'a> ProjectInitPlan<'a> {
let mut repo_configs = Vec::with_capacity(project.config.repos.len());

for repo_config in &project.config.repos {
if !filters.keeps_repo(repo_config) {
continue;
}
if let config::Repo::Remote(repo) = repo_config {
if !filters.keeps_remote_repo(project, repo) {
continue;
Expand Down
Loading
Loading