From 4172f806cda19d1714e5acde218a998c9abe6caf Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:34:33 +0800 Subject: [PATCH] Add tag filters to update configuration --- crates/prek/src/cli/mod.rs | 85 +++++++- crates/prek/src/cli/update/mod.rs | 295 ++++++++++++++------------- crates/prek/src/cli/update/source.rs | 86 ++++---- crates/prek/src/config.rs | 165 +++++++++++++-- crates/prek/src/schema.rs | 21 +- crates/prek/src/settings.rs | 243 ++++++++++++++++++++-- crates/prek/tests/update.rs | 160 ++++++++++++++- docs/compatibility.md | 3 +- docs/configuration.md | 3 +- docs/reference/cli.md | 8 +- docs/reference/configuration.md | 113 +++++----- prek.schema.json | 38 ++++ 12 files changed, 921 insertions(+), 299 deletions(-) diff --git a/crates/prek/src/cli/mod.rs b/crates/prek/src/cli/mod.rs index d554fa0ad..a14e1d6c9 100644 --- a/crates/prek/src/cli/mod.rs +++ b/crates/prek/src/cli/mod.rs @@ -6,6 +6,7 @@ use clap::builder::styling::{AnsiColor, Effects}; use clap::builder::{ArgPredicate, PathBufValueParser, Styles, TypedValueParser}; use clap::{ArgAction, Args, Parser, Subcommand, ValueHint}; use clap_complete::engine::ArgValueCompleter; +use globset::Glob; use prek_consts::env_vars::EnvVars; use serde::{Deserialize, Serialize}; @@ -784,32 +785,46 @@ pub(crate) struct UpdateArgs { #[arg(long, value_name = "REPO")] pub(crate) exclude_repo: Vec, /// Only consider tags matching this glob pattern. This option may be specified multiple times. + /// Defaults to `update.include_tags` in the project or global config when unset. /// /// For example, use `--include-tag 'v*'` to only consider version tags and ignore tags such as `nightly`. #[arg(long, value_name = "PATTERN", conflicts_with = "bleeding_edge")] - pub(crate) include_tag: Vec, + pub(crate) include_tag: Vec, /// Ignore tags matching this glob pattern. This option may be specified multiple times. + /// Defaults to `update.exclude_tags` in the project or global config when unset. /// /// For example, use `--exclude-tag nightly` to skip a moving tag, or /// `--exclude-tag '*-{alpha,beta,rc}*'` to skip common prerelease tags. #[arg(long, value_name = "PATTERN", conflicts_with = "bleeding_edge")] - pub(crate) exclude_tag: Vec, + pub(crate) exclude_tag: Vec, /// Only consider tags matching this glob pattern for a repository (`=`). /// This option may be specified multiple times. + /// Overrides the effective include filters for the named repository. /// /// When set for a repository, this overrides any global `--include-tag` filters for that repository. /// /// For example, use `--repo-include-tag https://github.com/example/repo=v*` to only consider version tags for one repository. - #[arg(long, value_name = "REPO=PATTERN", conflicts_with = "bleeding_edge")] - pub(crate) repo_include_tag: Vec, + #[arg( + long, + value_name = "REPO=PATTERN", + value_parser = parse_repo_tag_pattern, + conflicts_with = "bleeding_edge" + )] + pub(crate) repo_include_tag: Vec, /// Ignore tags matching this glob pattern for a repository (`=`). /// This option may be specified multiple times. + /// Adds to the effective `update` exclude filters for the named repository. /// /// Repo-specific exclude filters are added to global `--exclude-tag` filters; matching either filter excludes the tag for that repository. /// /// For example, use `--repo-exclude-tag https://github.com/example/repo=nightly` or `--repo-exclude-tag https://github.com/example/repo=*-rc*` to skip nightly or prerelease tags for one repository. - #[arg(long, value_name = "REPO=PATTERN", conflicts_with = "bleeding_edge")] - pub(crate) repo_exclude_tag: Vec, + #[arg( + long, + value_name = "REPO=PATTERN", + value_parser = parse_repo_tag_pattern, + conflicts_with = "bleeding_edge" + )] + pub(crate) repo_exclude_tag: Vec, /// Do not write changes to the config file, only display what would be changed. #[arg(long)] pub(crate) dry_run: bool, @@ -832,6 +847,26 @@ pub(crate) struct UpdateArgs { pub(crate) cooldown_days: Option, } +#[derive(Clone, Debug)] +pub(crate) struct RepoTagPattern { + pub(crate) repo: String, + pub(crate) pattern: Glob, +} + +fn parse_repo_tag_pattern(value: &str) -> Result { + let Some((repo, pattern)) = value.rsplit_once('=') else { + return Err("expected `=`".to_string()); + }; + if repo.is_empty() || pattern.is_empty() { + return Err("repo and pattern must not be empty".to_string()); + } + + Ok(RepoTagPattern { + repo: repo.to_string(), + pattern: pattern.parse::().map_err(|err| err.to_string())?, + }) +} + #[derive(Debug, Args)] pub(crate) struct HookImplArgs { /// Include the specified hooks or projects. @@ -1000,6 +1035,44 @@ pub(crate) struct InitTemplateDirArgs { pub(crate) hook_types: Vec, } +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::Cli; + + #[test] + fn update_rejects_invalid_repo_tag_pattern_during_cli_parsing() { + let Err(err) = Cli::try_parse_from([ + "prek", + "update", + "--repo-include-tag", + "https://example.com/repo", + ]) else { + panic!("expected invalid repo tag pattern to fail"); + }; + + insta::assert_snapshot!(err.to_string(), @r" + error: invalid value 'https://example.com/repo' for '--repo-include-tag ': expected `=` + + For more information, try '--help'. + "); + } + + #[test] + fn update_rejects_invalid_glob_during_cli_parsing() { + let Err(err) = Cli::try_parse_from(["prek", "update", "--include-tag", "["]) else { + panic!("expected invalid tag pattern to fail"); + }; + + insta::assert_snapshot!(err.to_string(), @r" + error: invalid value '[' for '--include-tag ': error parsing glob '[': unclosed character class; missing ']' + + For more information, try '--help'. + "); + } +} + #[cfg(test)] mod _gen { use crate::cli::Cli; diff --git a/crates/prek/src/cli/update/mod.rs b/crates/prek/src/cli/update/mod.rs index 7df4f200c..470a1db83 100644 --- a/crates/prek/src/cli/update/mod.rs +++ b/crates/prek/src/cli/update/mod.rs @@ -1,22 +1,24 @@ +use std::collections::{BTreeMap, BTreeSet}; use std::ops::Range; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use futures_util::{StreamExt, TryStreamExt}; +use globset::Glob; use rustc_hash::{FxHashMap, FxHashSet}; use semver::Version; -use crate::cli::ExitStatus; use crate::cli::reporter::UpdateReporter; use crate::cli::run::Selectors; use crate::cli::update::config::write_new_config; use crate::cli::update::display::{apply_repo_updates, warn_frozen_mismatches}; use crate::cli::update::source::{collect_repo_sources, evaluate_repo_source}; -use crate::config::GlobPatterns; -use crate::fs::CWD; +use crate::cli::{ExitStatus, RepoTagPattern}; +use crate::config::{GlobPatterns, Repo}; +use crate::fs::{CWD, Simplified}; use crate::printer::Printer; use crate::run::INTERNAL_CONCURRENCY; -use crate::settings::FilesystemOptions; +use crate::settings::{CliTagFilterOptions, FilesystemOptions, TagFilterOptions}; use crate::store::Store; use crate::warn_user; use crate::workspace::{Project, Workspace}; @@ -61,6 +63,8 @@ struct RepoTarget<'a> { cooldown_days: u8, /// Whether the selected revision should be stored as a frozen commit hash. freeze: bool, + /// Tag filters to apply when selecting an update for this target. + tag_filters: TagFilters, /// The sorted hook ids that must still exist after updating this target. required_hook_ids: Vec<&'a str>, /// Every config usage that shares this exact target configuration. @@ -194,112 +198,137 @@ enum RevisionSelection { } struct TagFilters { - global_include: GlobPatterns, - global_exclude: GlobPatterns, - repo_include: FxHashMap, - repo_exclude: FxHashMap, + include: GlobPatterns, + exclude: GlobPatterns, } impl TagFilters { - fn new( - include_tag: Vec, - exclude_tag: Vec, - repo_include_tag: Vec, - repo_exclude_tag: Vec, - ) -> Result { + fn from_options(options: TagFilterOptions) -> Result { Ok(Self { - global_include: GlobPatterns::new(include_tag) - .context("Invalid --include-tag pattern")?, - global_exclude: GlobPatterns::new(exclude_tag) - .context("Invalid --exclude-tag pattern")?, - repo_include: build_repo_tag_patterns(repo_include_tag, "--repo-include-tag")?, - repo_exclude: build_repo_tag_patterns(repo_exclude_tag, "--repo-exclude-tag")?, + include: GlobPatterns::from_globs(options.include) + .context("Failed to compile update include_tags patterns")?, + exclude: GlobPatterns::from_globs(options.exclude) + .context("Failed to compile update exclude_tags patterns")?, }) } - fn filter<'a>(&self, repo: &str, tags: &'a [TagTimestamp]) -> Vec<&'a TagTimestamp> { + fn filter<'a>(&self, tags: &'a [TagTimestamp]) -> Vec<&'a TagTimestamp> { tags.iter() - .filter(|tag| self.is_included(repo, &tag.tag) && !self.is_excluded(repo, &tag.tag)) + .filter(|tag| self.is_included(&tag.tag) && !self.is_excluded(&tag.tag)) .collect() } - /// Returns whether a tag passes include filters for a repository. - /// - /// Repo-specific include filters override global include filters for that repo. - fn is_included(&self, repo: &str, tag: &str) -> bool { - if let Some(repo_include) = self.repo_include.get(repo) { - return repo_include.is_empty() || repo_include.is_match(Path::new(tag)); - } + fn is_included(&self, tag: &str) -> bool { + self.include.is_empty() || self.include.is_match(Path::new(tag)) + } - self.global_include.is_empty() || self.global_include.is_match(Path::new(tag)) + fn is_excluded(&self, tag: &str) -> bool { + self.exclude.is_match(Path::new(tag)) } +} - /// Returns whether a tag matches any global or repo-specific exclude filter. - fn is_excluded(&self, repo: &str, tag: &str) -> bool { - self.global_exclude.is_match(Path::new(tag)) - || self - .repo_exclude - .get(repo) - .is_some_and(|set| set.is_match(Path::new(tag))) +fn group_repo_tag_patterns(values: Vec) -> BTreeMap> { + let mut patterns_by_repo: BTreeMap> = BTreeMap::new(); + for RepoTagPattern { repo, pattern } in values { + patterns_by_repo.entry(repo).or_default().push(pattern); } + patterns_by_repo +} + +fn project_has_repo(project: &Project, repo: &str) -> bool { + project.config().repos.iter().any( + |configured_repo| matches!(configured_repo, Repo::Remote(remote) if remote.repo() == repo), + ) } -fn build_repo_tag_patterns( - values: Vec, - option: &str, -) -> Result> { - let mut patterns_by_repo: FxHashMap> = FxHashMap::default(); - for value in values { - let (repo, pattern) = value.rsplit_once('=').ok_or_else(|| { - anyhow::anyhow!("Invalid {option} value `{value}`: expected `=`") - })?; - if repo.is_empty() || pattern.is_empty() { - anyhow::bail!("Invalid {option} value `{value}`: expected `=`"); +fn warn_missing_cli_repos( + configured_repos: &FxHashSet<&str>, + filter_repos: &[String], + cli_tag_filters: &CliTagFilterOptions, +) { + let mut missing_selectors = BTreeSet::new(); + missing_selectors.extend( + filter_repos + .iter() + .filter(|repo| !configured_repos.contains(repo.as_str())) + .map(|repo| (0, format!("--repo={repo}"))), + ); + for (order, option, patterns_by_repo) in [ + (1, "--repo-include-tag", &cli_tag_filters.repo_include), + (2, "--repo-exclude-tag", &cli_tag_filters.repo_exclude), + ] { + for (repo, patterns) in patterns_by_repo { + if configured_repos.contains(repo.as_str()) { + continue; + } + missing_selectors.extend( + patterns + .iter() + .map(|pattern| (order, format!("{option}={repo}={}", pattern.glob()))), + ); } - patterns_by_repo - .entry(repo.to_string()) - .or_default() - .push(pattern.to_string()); } - - patterns_by_repo + let missing_selectors = missing_selectors .into_iter() - .map(|(repo, patterns)| { - Ok(( - repo, - GlobPatterns::new(patterns).with_context(|| format!("Invalid {option} pattern"))?, - )) + .map(|(_, selector)| selector) + .collect::>(); + + match missing_selectors.as_slice() { + [] => {} + [selector] => { + warn_user!("repository selector `{selector}` was not found in the configuration"); + } + _ => { + warn_user!("the following repository selectors were not found in the configuration:"); + for selector in missing_selectors { + anstream::eprintln!(" - `{selector}`"); + } + } + } +} + +fn warn_missing_project_repos(workspace: &Workspace) { + let missing_project_repos = workspace + .projects() + .iter() + .filter_map(|project| { + let repos = project + .config() + .update + .iter() + .flat_map(|options| options.repos.keys()) + .filter(|repo| !project_has_repo(project, repo)) + .collect::>(); + (!repos.is_empty()).then_some((project.config_file(), repos)) }) - .collect() + .collect::>(); + + if !missing_project_repos.is_empty() { + warn_user!( + "the following `update.repos` entries were not found in their project configurations:" + ); + for (config_file, repos) in missing_project_repos { + anstream::eprintln!(" `{}`:", config_file.user_display()); + for repo in repos { + anstream::eprintln!(" - `{repo}`"); + } + } + } } fn warn_missing_repos( + workspace: &Workspace, repo_sources: &[RepoSource<'_>], filter_repos: &[String], - tag_filters: &TagFilters, + cli_tag_filters: &CliTagFilterOptions, ) { let configured_repos = repo_sources .iter() .flat_map(|repo_source| repo_source.targets.iter().map(|target| target.repo)) .collect::>(); - let mut missing_repos = filter_repos - .iter() - .map(String::as_str) - .chain(tag_filters.repo_include.keys().map(String::as_str)) - .chain(tag_filters.repo_exclude.keys().map(String::as_str)) - .filter(|repo| !configured_repos.contains(*repo)) - .collect::>(); - missing_repos.sort_unstable(); - missing_repos.dedup(); - match missing_repos.as_slice() { - [] => {} - [repo] => warn_user!("repo `{repo}` was not found in the configuration"), - _ => warn_user!( - "repos `{}` were not found in the configuration", - missing_repos.join("`, `") - ), - } + warn_missing_cli_repos(&configured_repos, filter_repos, cli_tag_filters); + warn_missing_project_repos(workspace); } /// The successful result of evaluating one configured `repo + rev + hook set` target. @@ -404,10 +433,10 @@ pub(crate) async fn update( config: Option, filter_repos: Vec, exclude_repos: Vec, - include_tag: Vec, - exclude_tag: Vec, - repo_include_tag: Vec, - repo_exclude_tag: Vec, + include_tag: Vec, + exclude_tag: Vec, + repo_include_tag: Vec, + repo_exclude_tag: Vec, verbose: bool, bleeding_edge: bool, freeze: bool, @@ -423,8 +452,13 @@ pub(crate) async fn update( let selectors = Selectors::default(); let workspace = Workspace::discover(store, workspace_root, config, Some(&selectors), true)?; - let tag_filters = - TagFilters::new(include_tag, exclude_tag, repo_include_tag, repo_exclude_tag)?; + let cli_tag_filters = CliTagFilterOptions { + include: include_tag, + exclude: exclude_tag, + repo_include: group_repo_tag_patterns(repo_include_tag), + repo_exclude: group_repo_tag_patterns(repo_exclude_tag), + }; + let jobs = if jobs == 0 { *INTERNAL_CONCURRENCY } else { @@ -432,9 +466,15 @@ pub(crate) async fn update( }; let reporter = UpdateReporter::new(printer); - let mut repo_sources = - collect_repo_sources(&workspace, freeze, cooldown_days, filesystem.as_ref())?; - warn_missing_repos(&repo_sources, &filter_repos, &tag_filters); + let mut repo_sources = collect_repo_sources( + &workspace, + freeze, + cooldown_days, + &cli_tag_filters, + filesystem.as_ref(), + )?; + warn_missing_repos(&workspace, &repo_sources, &filter_repos, &cli_tag_filters); + for repo_source in &mut repo_sources { repo_source.targets.retain(|target| { (filter_repos.is_empty() || filter_repos.iter().any(|repo| repo == target.repo)) @@ -450,7 +490,7 @@ pub(crate) async fn update( .first() .map_or(repo_source.source, |target| target.repo); let progress = reporter.on_update_start(display_repo); - let result = evaluate_repo_source(repo_source, bleeding_edge, &tag_filters).await; + let result = evaluate_repo_source(repo_source, bleeding_edge).await; reporter.on_update_complete(progress); result }) @@ -492,55 +532,47 @@ mod tests { TagTimestamp::new(name.to_string(), 0, String::new()) } - fn filtered_tags(filters: &TagFilters, repo: &str, tags: &[TagTimestamp]) -> Vec { + fn filtered_tags(filters: &TagFilters, tags: &[TagTimestamp]) -> Vec { filters - .filter(repo, tags) + .filter(tags) .into_iter() .map(|tag| tag.tag.clone()) .collect() } + fn tag_filters(include: &[&str], exclude: &[&str]) -> TagFilters { + TagFilters::from_options(TagFilterOptions { + include: include + .iter() + .map(|pattern| pattern.parse().unwrap()) + .collect(), + exclude: exclude + .iter() + .map(|pattern| pattern.parse().unwrap()) + .collect(), + }) + .unwrap() + } + #[test] fn tag_filters_keep_all_tags_without_filters() { - let filters = TagFilters::new(Vec::new(), Vec::new(), Vec::new(), Vec::new()).unwrap(); + let filters = tag_filters(&[], &[]); let tags = [tag("v1.0.0"), tag("nightly")]; - assert_eq!( - filtered_tags(&filters, "https://example.com/repo", &tags), - vec!["v1.0.0", "nightly"] - ); + assert_eq!(filtered_tags(&filters, &tags), vec!["v1.0.0", "nightly"]); } #[test] - fn tag_filters_repo_include_overrides_global_include() { - let filters = TagFilters::new( - vec!["v1.*".to_string()], - Vec::new(), - vec!["https://example.com/repo=v*.1.0".to_string()], - Vec::new(), - ) - .unwrap(); + fn tag_filters_apply_include_patterns() { + let filters = tag_filters(&["v*.1.0"], &[]); let tags = [tag("v1.0.0"), tag("v1.1.0"), tag("v2.1.0")]; - assert_eq!( - filtered_tags(&filters, "https://example.com/repo", &tags), - vec!["v1.1.0", "v2.1.0"] - ); - assert_eq!( - filtered_tags(&filters, "https://example.com/other", &tags), - vec!["v1.0.0", "v1.1.0"] - ); + assert_eq!(filtered_tags(&filters, &tags), vec!["v1.1.0", "v2.1.0"]); } #[test] fn tag_filters_apply_excludes_after_includes() { - let filters = TagFilters::new( - vec!["v*".to_string()], - vec!["*-rc*".to_string()], - Vec::new(), - vec!["https://example.com/repo=v2.*".to_string()], - ) - .unwrap(); + let filters = tag_filters(&["v*"], &["v2.*", "*-rc*"]); let tags = [ tag("v1.0.0"), tag("v2.0.0"), @@ -548,31 +580,6 @@ mod tests { tag("nightly"), ]; - assert_eq!( - filtered_tags(&filters, "https://example.com/repo", &tags), - vec!["v1.0.0"] - ); - assert_eq!( - filtered_tags(&filters, "https://example.com/other", &tags), - vec!["v1.0.0", "v2.0.0"] - ); - } - - #[test] - fn tag_filters_reject_invalid_repo_filter_values() { - let result = TagFilters::new( - Vec::new(), - Vec::new(), - vec!["https://example.com/repo".to_string()], - Vec::new(), - ); - - match result { - Ok(_) => panic!("expected invalid repo tag filter to fail"), - Err(err) => assert!( - err.to_string().contains("expected `=`"), - "{err:#}" - ), - } + assert_eq!(filtered_tags(&filters, &tags), vec!["v1.0.0"]); } } diff --git a/crates/prek/src/cli/update/source.rs b/crates/prek/src/cli/update/source.rs index 3c229c14e..35ff0a62d 100644 --- a/crates/prek/src/cli/update/source.rs +++ b/crates/prek/src/cli/update/source.rs @@ -1,4 +1,3 @@ -use std::collections::hash_map::Entry; use std::path::Path; use anyhow::{Context, Result}; @@ -18,7 +17,7 @@ use crate::cli::update::{ }; use crate::config::{Repo, looks_like_sha}; use crate::fs::Simplified; -use crate::settings::{FilesystemOptions, UpdateSettings}; +use crate::settings::{CliTagFilterOptions, FilesystemOptions, TagFilterOptions, UpdateSettings}; use crate::workspace::Workspace; /// Identifies repo usages that can share one update evaluation. @@ -26,12 +25,27 @@ use crate::workspace::Workspace; struct RepoTargetKey<'a> { repo: &'a str, current_rev: &'a str, - required_hook_ids: Vec<&'a str>, cooldown_days: u8, freeze: bool, + tag_filters: TagFilterOptions, + required_hook_ids: Vec<&'a str>, +} + +impl<'a> RepoTargetKey<'a> { + fn into_repo_target(self, usages: Vec>) -> Result> { + Ok(RepoTarget { + repo: self.repo, + current_rev: self.current_rev, + cooldown_days: self.cooldown_days, + freeze: self.freeze, + tag_filters: TagFilters::from_options(self.tag_filters)?, + required_hook_ids: self.required_hook_ids, + usages, + }) + } } -type RepoTargetsByKey<'a> = FxHashMap, RepoTarget<'a>>; +type RepoTargetsByKey<'a> = FxHashMap, Vec>>; type RepoSourcesBySource<'a> = FxHashMap<&'a str, RepoTargetsByKey<'a>>; /// Collects configured remote repos grouped by source, configured value, revision, and settings. @@ -39,16 +53,13 @@ pub(super) fn collect_repo_sources<'a>( workspace: &'a Workspace, cli_freeze: bool, cli_cooldown_days: Option, + cli_tag_filters: &CliTagFilterOptions, filesystem: Option<&FilesystemOptions>, ) -> Result>> { let mut repo_sources: RepoSourcesBySource<'a> = FxHashMap::default(); for project in workspace.projects() { let project_update = project.config().update.as_ref(); - let UpdateSettings { - cooldown_days, - freeze, - } = UpdateSettings::resolve(cli_freeze, cli_cooldown_days, project_update, filesystem); let remote_count = project .config() .repos @@ -77,6 +88,18 @@ pub(super) fn collect_repo_sources<'a>( let Repo::Remote(remote_repo) = repo else { continue; }; + let UpdateSettings { + cooldown_days, + freeze, + tag_filters, + } = UpdateSettings::resolve( + cli_freeze, + cli_cooldown_days, + remote_repo.repo(), + cli_tag_filters, + project_update, + filesystem, + ); let mut required_hook_ids = remote_repo .hooks @@ -90,25 +113,12 @@ pub(super) fn collect_repo_sources<'a>( let target_key = RepoTargetKey { repo: remote_repo.repo(), current_rev: remote_repo.rev.as_str(), - required_hook_ids, cooldown_days, freeze, + tag_filters, + required_hook_ids, }; - let target = match targets.entry(target_key) { - Entry::Occupied(entry) => entry.into_mut(), - Entry::Vacant(entry) => { - let required_hook_ids = entry.key().required_hook_ids.clone(); - entry.insert(RepoTarget { - repo: remote_repo.repo(), - current_rev: remote_repo.rev.as_str(), - cooldown_days, - freeze, - required_hook_ids, - usages: Vec::new(), - }) - } - }; - target.usages.push(RepoUsage { + targets.entry(target_key).or_default().push(RepoUsage { project, remote_count, remote_index, @@ -120,21 +130,19 @@ pub(super) fn collect_repo_sources<'a>( } } - Ok(repo_sources + repo_sources .into_iter() .map(|(source, targets)| { - let mut targets = targets.into_values().collect::>(); - targets.sort_by(|a, b| { - a.repo - .cmp(b.repo) - .then_with(|| a.current_rev.cmp(b.current_rev)) - .then_with(|| a.cooldown_days.cmp(&b.cooldown_days)) - .then_with(|| a.freeze.cmp(&b.freeze)) - .then_with(|| a.required_hook_ids.cmp(&b.required_hook_ids)) - }); - RepoSource { source, targets } + let mut targets = targets.into_iter().collect::>(); + // The first repo is used as the progress label; result events are sorted separately. + targets.sort_unstable_by_key(|(target, _)| target.repo); + let targets = targets + .into_iter() + .map(|(target_key, usages)| target_key.into_repo_target(usages)) + .collect::>>()?; + Ok(RepoSource { source, targets }) }) - .collect()) + .collect() } /// Collects stale `# frozen:` comments for one configured `repo + rev + hook set` target. @@ -208,7 +216,6 @@ async fn collect_frozen_mismatches<'a>( pub(super) async fn evaluate_repo_source<'a>( repo_source: &'a RepoSource<'a>, bleeding_edge: bool, - tag_filters: &TagFilters, ) -> Result>> { let tmp_dir = tempfile::tempdir()?; let repo_path = tmp_dir.path(); @@ -243,8 +250,9 @@ pub(super) async fn evaluate_repo_source<'a>( let mut updates = Vec::with_capacity(repo_source.targets.len()); for target in &repo_source.targets { - let update_tag_timestamps = tag_filters - .filter(target.repo, &tag_timestamps) + let update_tag_timestamps = target + .tag_filters + .filter(&tag_timestamps) .into_iter() .cloned() .collect::>(); diff --git a/crates/prek/src/config.rs b/crates/prek/src/config.rs index 4e6ba078f..a5232a325 100644 --- a/crates/prek/src/config.rs +++ b/crates/prek/src/config.rs @@ -5,7 +5,7 @@ use std::path::Path; use anyhow::Result; use fancy_regex::Regex; -use globset::{Glob, GlobSet, GlobSetBuilder}; +use globset::{Glob, GlobSet}; use itertools::Itertools; use owo_colors::OwoColorize; use prek_identify::TagSet; @@ -47,15 +47,23 @@ where #[derive(Clone)] pub(crate) struct GlobPatterns { - patterns: Vec, + patterns: Vec, set: GlobSet, } impl GlobPatterns { pub(crate) fn new(patterns: Vec) -> Result { - let mut builder = GlobSetBuilder::new(); + let patterns = patterns + .into_iter() + .map(|pattern| pattern.parse()) + .collect::, _>>()?; + Self::from_globs(patterns) + } + + pub(crate) fn from_globs(patterns: Vec) -> Result { + let mut builder = GlobSet::builder(); for pattern in &patterns { - builder.add(Glob::new(pattern)?); + builder.add(pattern.clone()); } let set = builder.build()?; Ok(Self { patterns, set }) @@ -70,10 +78,18 @@ impl GlobPatterns { } } +fn debug_globs(globs: &[Glob]) -> impl std::fmt::Debug + '_ { + std::fmt::from_fn(|f| { + f.debug_list() + .entries(globs.iter().map(Glob::glob)) + .finish() + }) +} + impl std::fmt::Debug for GlobPatterns { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("GlobPatterns") - .field("patterns", &self.patterns) + .field("patterns", &debug_globs(&self.patterns)) .finish_non_exhaustive() } } @@ -1171,6 +1187,65 @@ where }) } +/// A configuration value that accepts either one string or a list of strings. +#[derive(Clone, Eq, PartialEq)] +pub(crate) enum StringOrList { + One(Glob), + Many(Vec), +} + +impl std::fmt::Debug for StringOrList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::One(pattern) => f.debug_tuple("One").field(&pattern.glob()).finish(), + Self::Many(patterns) => f.debug_tuple("Many").field(&debug_globs(patterns)).finish(), + } + } +} + +impl<'de> Deserialize<'de> for StringOrList { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum RawStringOrList { + One(String), + Many(Vec), + } + + match RawStringOrList::deserialize(deserializer)? { + RawStringOrList::One(pattern) => { + pattern.parse().map(Self::One).map_err(D::Error::custom) + } + RawStringOrList::Many(patterns) => patterns + .into_iter() + .map(|pattern| pattern.parse().map_err(D::Error::custom)) + .collect::>() + .map(Self::Many), + } + } +} + +impl StringOrList { + pub(crate) fn as_slice(&self) -> &[Glob] { + match self { + Self::One(value) => std::slice::from_ref(value), + Self::Many(values) => values, + } + } +} + +/// Overrides tag selection for one repository during `prek update`. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, rename_all = "snake_case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub(crate) struct RepoTagFilterOptions { + pub(crate) include_tags: Option, + pub(crate) exclude_tags: Option, +} + /// Controls how `prek update` selects eligible releases. #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, rename_all = "snake_case")] @@ -1178,6 +1253,9 @@ where pub(crate) struct UpdateOptions { pub(crate) cooldown_days: Option, pub(crate) freeze: Option, + pub(crate) include_tags: Option, + pub(crate) exclude_tags: Option, + pub(crate) repos: BTreeMap, } // TODO: warn sensible regex @@ -2013,20 +2091,81 @@ mod tests { #[test] fn parse_update_options() { - let yaml = indoc::indoc! {r" + let yaml = indoc::indoc! {r#" update: cooldown_days: 7 freeze: true + include_tags: "v*" + exclude_tags: ["*-rc*"] + repos: + "https://example.com/repo": + include_tags: "v1.*" + exclude_tags: [nightly, "*-dev*"] repos: [] - "}; + "#}; let result = serde_saphyr::from_str::(yaml).unwrap(); + let options = result.update.unwrap(); - assert_eq!( - result - .update - .map(|options| (options.cooldown_days, options.freeze)), - Some((Some(7), Some(true))) - ); + insta::assert_debug_snapshot!(options, @r###" + UpdateOptions { + cooldown_days: Some( + 7, + ), + freeze: Some( + true, + ), + include_tags: Some( + One( + "v*", + ), + ), + exclude_tags: Some( + Many( + [ + "*-rc*", + ], + ), + ), + repos: { + "https://example.com/repo": RepoTagFilterOptions { + include_tags: Some( + One( + "v1.*", + ), + ), + exclude_tags: Some( + Many( + [ + "nightly", + "*-dev*", + ], + ), + ), + }, + }, + } + "###); + } + + #[test] + fn parse_update_options_rejects_invalid_glob() { + let yaml = indoc::indoc! {r#" + update: + include_tags: "[" + repos: [] + "#}; + let err = serde_saphyr::from_str::(yaml).unwrap_err(); + + insta::assert_snapshot!(err, @r#" + error: line 2 column 17: error parsing glob '[': unclosed character class; missing ']' + --> :2:17 + | + 1 | update: + 2 | include_tags: "[" + | ^ error parsing glob '[': unclosed character class; missing ']' + 3 | repos: [] + | + "#); } #[test] diff --git a/crates/prek/src/schema.rs b/crates/prek/src/schema.rs index 095e2936a..a55cfbe82 100644 --- a/crates/prek/src/schema.rs +++ b/crates/prek/src/schema.rs @@ -1,6 +1,6 @@ use crate::config::{ BuiltinHook, BuiltinRepo, FilePattern, LocalHook, LocalRepo, MetaHook, MetaRepo, PassFilenames, - RemoteHook, RemoteRepo, Repo, Stage, Stages, + RemoteHook, RemoteRepo, Repo, Stage, Stages, StringOrList, }; use std::borrow::Cow; @@ -204,6 +204,25 @@ impl schemars::JsonSchema for FilePattern { } } +impl schemars::JsonSchema for StringOrList { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("StringOrList") + } + + fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "description": "A configuration value that accepts either one string or a list of strings.", + "anyOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + }) + } +} + impl schemars::JsonSchema for PassFilenames { fn schema_name() -> std::borrow::Cow<'static, str> { std::borrow::Cow::Borrowed("PassFilenames") diff --git a/crates/prek/src/settings.rs b/crates/prek/src/settings.rs index 3e4dd788a..49db73fe0 100644 --- a/crates/prek/src/settings.rs +++ b/crates/prek/src/settings.rs @@ -1,13 +1,15 @@ +use std::collections::BTreeMap; use std::io::ErrorKind; use std::ops::Deref; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use etcetera::BaseStrategy; +use globset::Glob; use prek_consts::env_vars::{EnvVars, EnvVarsRead}; use serde::Deserialize; -use crate::config::UpdateOptions as ProjectUpdateOptions; +use crate::config::{StringOrList, UpdateOptions as ProjectUpdateOptions}; fn user_config_path() -> Option { if let Some(path) = EnvVars.var_os(EnvVars::PREK_INTERNAL__USER_CONFIG_PATH) { @@ -81,28 +83,97 @@ impl Deref for FilesystemOptions { #[serde(default, rename_all = "snake_case")] pub(crate) struct Options { #[serde(alias = "auto_update")] - update: Option, + update: Option, } -/// Options for the `update` command. +/// Default update options represented in the global `prek.toml` file. #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, rename_all = "snake_case")] -struct UpdateOptions { +struct GlobalUpdateOptions { cooldown_days: Option, freeze: Option, + include_tags: Option, + exclude_tags: Option, +} + +/// Tag filters supplied on the command line. +#[derive(Debug, Clone, Default)] +pub(crate) struct CliTagFilterOptions { + pub(crate) include: Vec, + pub(crate) exclude: Vec, + pub(crate) repo_include: BTreeMap>, + pub(crate) repo_exclude: BTreeMap>, +} + +/// Effective tag filters for one configured repository. +#[derive(Debug, Clone, Default, Eq, Hash, PartialEq)] +pub(crate) struct TagFilterOptions { + pub(crate) include: Vec, + pub(crate) exclude: Vec, +} + +impl TagFilterOptions { + fn resolve( + repo: &str, + cli: &CliTagFilterOptions, + project: Option<&ProjectUpdateOptions>, + filesystem: Option<&GlobalUpdateOptions>, + ) -> Self { + fn resolve_config_filter( + repo: Option<&StringOrList>, + project: Option<&StringOrList>, + filesystem: Option<&StringOrList>, + ) -> Vec { + repo.or(project) + .or(filesystem) + .map(StringOrList::as_slice) + .unwrap_or_default() + .to_vec() + } + + let repo_options = project.and_then(|options| options.repos.get(repo)); + let mut include = resolve_config_filter( + repo_options.and_then(|options| options.include_tags.as_ref()), + project.and_then(|options| options.include_tags.as_ref()), + filesystem.and_then(|options| options.include_tags.as_ref()), + ); + let mut exclude = resolve_config_filter( + repo_options.and_then(|options| options.exclude_tags.as_ref()), + project.and_then(|options| options.exclude_tags.as_ref()), + filesystem.and_then(|options| options.exclude_tags.as_ref()), + ); + + if !cli.include.is_empty() { + include.clone_from(&cli.include); + } + if !cli.exclude.is_empty() { + exclude.clone_from(&cli.exclude); + } + if let Some(repo_include) = cli.repo_include.get(repo) { + include.clone_from(repo_include); + } + if let Some(repo_exclude) = cli.repo_exclude.get(repo) { + exclude.extend(repo_exclude.iter().cloned()); + } + + Self { include, exclude } + } } /// Resolved settings for the `update` command. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) struct UpdateSettings { pub(crate) cooldown_days: u8, pub(crate) freeze: bool, + pub(crate) tag_filters: TagFilterOptions, } impl UpdateSettings { pub(crate) fn resolve( cli_freeze: bool, cli_cooldown_days: Option, + repo: &str, + cli_tag_filters: &CliTagFilterOptions, project: Option<&ProjectUpdateOptions>, filesystem: Option<&FilesystemOptions>, ) -> Self { @@ -117,34 +188,78 @@ impl UpdateSettings { .and_then(|options| options.freeze) .or_else(|| filesystem_update.and_then(|options| options.freeze)) .unwrap_or_default(), + tag_filters: TagFilterOptions::resolve( + repo, + cli_tag_filters, + project, + filesystem_update, + ), } } } #[cfg(test)] mod tests { - use super::{FilesystemOptions, Options, UpdateSettings}; + use std::collections::BTreeMap; + + use super::{CliTagFilterOptions, FilesystemOptions, Options, UpdateSettings}; + use globset::Glob; + use crate::config::UpdateOptions as ProjectUpdateOptions; + fn glob_pattern(pattern: &str) -> Glob { + pattern.parse().unwrap() + } + + fn pattern_strings(patterns: &[Glob]) -> Vec<&str> { + patterns.iter().map(Glob::glob).collect() + } + #[test] fn options_deserializes_update_settings() { let options: Options = toml::from_str( - r" + r#" [update] cooldown_days = 7 freeze = true - ", + include_tags = "v*" + exclude_tags = ["*-rc*"] + "#, ) .unwrap(); + let options = options.update.unwrap(); + assert_eq!(options.cooldown_days, Some(7)); + assert_eq!(options.freeze, Some(true)); + assert_eq!( + pattern_strings(options.include_tags.unwrap().as_slice()), + ["v*"] + ); assert_eq!( - options - .update - .map(|options| (options.cooldown_days, options.freeze)), - Some((Some(7), Some(true))) + pattern_strings(options.exclude_tags.unwrap().as_slice()), + ["*-rc*"] ); } + #[test] + fn options_rejects_invalid_update_glob() { + let err = toml::from_str::( + r#" + [update] + exclude_tags = ["v*", "["] + "#, + ) + .unwrap_err(); + + insta::assert_snapshot!(err, @r#" + TOML parse error at line 3, column 28 + | + 3 | exclude_tags = ["v*", "["] + | ^^^^^^^^^^^ + error parsing glob '[': unclosed character class; missing ']' + "#); + } + #[test] fn options_deserializes_legacy_update_key_alias() { let options: Options = toml::from_str( @@ -173,7 +288,14 @@ mod tests { .unwrap(), ); - let settings = UpdateSettings::resolve(false, None, None, Some(&filesystem)); + let settings = UpdateSettings::resolve( + false, + None, + "https://example.com/repo", + &CliTagFilterOptions::default(), + None, + Some(&filesystem), + ); assert!(settings.freeze); } @@ -193,8 +315,16 @@ mod tests { let project = ProjectUpdateOptions { cooldown_days: None, freeze: Some(false), + ..ProjectUpdateOptions::default() }; - let settings = UpdateSettings::resolve(false, None, Some(&project), Some(&filesystem)); + let settings = UpdateSettings::resolve( + false, + None, + "https://example.com/repo", + &CliTagFilterOptions::default(), + Some(&project), + Some(&filesystem), + ); assert!(!settings.freeze); } @@ -204,9 +334,92 @@ mod tests { let project = ProjectUpdateOptions { cooldown_days: None, freeze: Some(false), + ..ProjectUpdateOptions::default() }; - let settings = UpdateSettings::resolve(true, None, Some(&project), None); + let settings = UpdateSettings::resolve( + true, + None, + "https://example.com/repo", + &CliTagFilterOptions::default(), + Some(&project), + None, + ); assert!(settings.freeze); } + + #[test] + fn update_settings_resolves_tag_filter_precedence_per_option() { + let filesystem = FilesystemOptions( + toml::from_str( + r#" + [update] + include_tags = "global-include" + exclude_tags = ["global-exclude"] + "#, + ) + .unwrap(), + ); + let project: ProjectUpdateOptions = toml::from_str( + r#" + include_tags = "project-include" + exclude_tags = ["project-exclude"] + + [repos."https://example.com/repo"] + include_tags = [] + exclude_tags = "repo-exclude" + "#, + ) + .unwrap(); + let cli = CliTagFilterOptions { + include: vec![glob_pattern("cli-include")], + repo_exclude: BTreeMap::from([( + "https://example.com/repo".to_string(), + vec![glob_pattern("cli-repo-exclude")], + )]), + ..CliTagFilterOptions::default() + }; + + let settings = UpdateSettings::resolve( + false, + None, + "https://example.com/repo", + &cli, + Some(&project), + Some(&filesystem), + ); + + assert_eq!( + pattern_strings(&settings.tag_filters.include), + ["cli-include"] + ); + assert_eq!( + pattern_strings(&settings.tag_filters.exclude), + ["repo-exclude", "cli-repo-exclude"] + ); + } + + #[test] + fn update_settings_empty_repo_filter_clears_project_default() { + let project: ProjectUpdateOptions = toml::from_str( + r#" + include_tags = "v*" + + [repos."https://example.com/repo"] + include_tags = [] + "#, + ) + .unwrap(); + + let settings = UpdateSettings::resolve( + false, + None, + "https://example.com/repo", + &CliTagFilterOptions::default(), + Some(&project), + None, + ); + + assert!(settings.tag_filters.include.is_empty()); + } } diff --git a/crates/prek/tests/update.rs b/crates/prek/tests/update.rs index 83875e51f..d25cfae3f 100644 --- a/crates/prek/tests/update.rs +++ b/crates/prek/tests/update.rs @@ -140,7 +140,7 @@ fn create_local_git_repo_with_tag_timestamps( .assert() .success(); - Ok(repo_dir.to_string_lossy().into_owned()) + Ok(repo_dir.to_string_lossy().replace('\\', "/")) } #[test] @@ -639,7 +639,72 @@ fn update_warns_for_missing_repos() -> Result<()> { updating rev `v1.0.0` -> `v1.1.0` ----- stderr ----- - warning: repos `missing-from-exclude`, `missing-from-include`, `missing-from-repo` were not found in the configuration + warning: the following repository selectors were not found in the configuration: + - `--repo=missing-from-repo` + - `--repo-include-tag=missing-from-include=v*` + - `--repo-exclude-tag=missing-from-exclude=nightly` + "); + + Ok(()) +} + +#[test] +fn update_warns_when_repo_override_matches_another_project() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let repo1_path = create_local_git_repo(&context, "project-repo-1", &["v1.0.0", "v1.1.0"])?; + let repo2_path = create_local_git_repo(&context, "project-repo-2", &["v1.0.0", "v1.1.0"])?; + + context.setup_workspace(&["project-a", "project-b"], "repos: []")?; + context + .work_dir() + .child("project-a/.pre-commit-config.yaml") + .write_str(&indoc::formatdoc! {r#" + update: + repos: + "{}": + exclude_tags: nightly + repos: + - repo: {} + rev: v1.0.0 + hooks: + - id: test-hook + "#, repo2_path, repo1_path})?; + context + .work_dir() + .child("project-b/.pre-commit-config.yaml") + .write_str(&indoc::formatdoc! {r#" + update: + repos: + "{}": + include_tags: "v*" + repos: + - repo: {} + rev: v1.0.0 + hooks: + - id: test-hook + "#, repo1_path, repo2_path})?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.update().arg("--jobs").arg("1"), @" + success: true + exit_code: 0 + ----- stdout ----- + project-a/.pre-commit-config.yaml + [HOME]/test-repos/project-repo-1 + updating rev `v1.0.0` -> `v1.1.0` + + project-b/.pre-commit-config.yaml + [HOME]/test-repos/project-repo-2 + updating rev `v1.0.0` -> `v1.1.0` + + ----- stderr ----- + warning: the following `update.repos` entries were not found in their project configurations: + `project-a/.pre-commit-config.yaml`: + - `[HOME]/test-repos/project-repo-2` + `project-b/.pre-commit-config.yaml`: + - `[HOME]/test-repos/project-repo-1` "); Ok(()) @@ -868,6 +933,85 @@ fn update_tag_filters_include_then_exclude() -> Result<()> { Ok(()) } +#[test] +fn update_uses_project_tag_filter_config() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let repo1_path = create_local_git_repo( + &context, + "tag-filter-config-1", + &["v1.0.0", "v2.0.0", "v2.1.0", "v3.0.0-rc1"], + )?; + let repo2_path = create_local_git_repo( + &context, + "tag-filter-config-2", + &["v1.0.0", "v2.0.0", "v2.1.0", "v3.0.0-rc1"], + )?; + + context.write_pre_commit_config(&indoc::formatdoc! {r#" + update: + include_tags: "v*" + exclude_tags: ["*-rc*"] + repos: + "{}": + include_tags: "v2.*" + exclude_tags: "v2.1.0" + repos: + - repo: {} + rev: v1.0.0 + hooks: + - id: test-hook + - repo: {} + rev: v1.0.0 + hooks: + - id: test-hook + "#, repo1_path, repo1_path, repo2_path}); + + context.git_add("."); + + let filters = context.filters(); + + cmd_snapshot!(filters.clone(), context.update().arg("--jobs").arg("1"), @" + success: true + exit_code: 0 + ----- stdout ----- + [HOME]/test-repos/tag-filter-config-1 + updating rev `v1.0.0` -> `v2.0.0` + + [HOME]/test-repos/tag-filter-config-2 + updating rev `v1.0.0` -> `v2.1.0` + + ----- stderr ----- + "); + + insta::with_settings!( + { filters => filters.clone() }, + { + assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#" + update: + include_tags: "v*" + exclude_tags: ["*-rc*"] + repos: + "[HOME]/test-repos/tag-filter-config-1": + include_tags: "v2.*" + exclude_tags: "v2.1.0" + repos: + - repo: [HOME]/test-repos/tag-filter-config-1 + rev: v2.0.0 + hooks: + - id: test-hook + - repo: [HOME]/test-repos/tag-filter-config-2 + rev: v2.1.0 + hooks: + - id: test-hook + "#); + } + ); + + Ok(()) +} + #[test] fn update_tag_filters_can_select_older_track_without_cooldown() -> Result<()> { let context = TestContext::new(); @@ -1983,7 +2127,7 @@ fn update_updates_mismatched_frozen_comment_toml() -> Result<()> { hooks = [ {{ id = "test-hook" }}, ] - "#, repo_path.replace('\\', "/"), commit_sha})?; + "#, repo_path, commit_sha})?; context.git_add("."); @@ -2801,7 +2945,7 @@ fn update_toml() -> Result<()> { hooks = [ {{ id = "test-hook" }}, ] - "#, repo_path.replace('\\', "/")})?; + "#, repo_path})?; context.git_add("."); let filters = context.filters(); @@ -2851,7 +2995,7 @@ fn update_toml_with_comment() -> Result<()> { hooks = [ {{ id = "test-hook" }}, ] - "#, repo_path.replace('\\', "/")})?; + "#, repo_path})?; context.git_add("."); @@ -2892,7 +3036,7 @@ fn update_toml_with_comment() -> Result<()> { hooks = [ {{ id = "test-hook" }}, ] - "#, repo_path.replace('\\', "/")})?; + "#, repo_path})?; context.git_add("."); @@ -2953,7 +3097,7 @@ fn update_freeze_toml() -> Result<()> { hooks = [ {{ id = "test-hook" }}, ] - "#, repo_path.replace('\\', "/")})?; + "#, repo_path})?; context.git_add("."); @@ -3190,7 +3334,7 @@ fn update_freeze_toml_with_comment() -> Result<()> { hooks = [ {{ id = "test-hook" }}, ] - "#, repo_path.replace('\\', "/")})?; + "#, repo_path})?; context.git_add("."); diff --git a/docs/compatibility.md b/docs/compatibility.md index f9bab5bff..61de3a6f6 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -30,8 +30,7 @@ | Compatibility spelling | Preferred `prek` spelling | | -- | -- | -| `auto_update.cooldown_days` | `update.cooldown_days` | -| `auto_update.freeze` | `update.freeze` | +| `auto_update` | `update` | ## Why the CLI is reorganized diff --git a/docs/configuration.md b/docs/configuration.md index 0154720b4..02753dfc5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,8 +50,7 @@ These entries are implemented by `prek` and are not part of the documented upstr They work in both YAML and TOML, but they only matter for compatibility if you share a YAML config with upstream `pre-commit`. - Top-level: - - [`update.cooldown_days`](reference/configuration.md#updatecooldown_days) - - [`update.freeze`](reference/configuration.md#updatefreeze) + - [`update`](reference/configuration.md#update) - [`default_env`](reference/configuration.md#default_env) - [`minimum_prek_version`](reference/configuration.md#prek-only-minimum-prek-version-config) - [`orphan`](reference/configuration.md#prek-only-orphan) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d8cc5b31b..21f9d3134 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -573,12 +573,12 @@ prek update [OPTIONS]

The age is computed from the tag creation timestamp for annotated tags, or from the tagged commit timestamp for lightweight tags. If the current rev is newer than the latest cooldown-eligible tag, prek update keeps the current rev instead of downgrading it. Defaults to update.cooldown_days in the project or global config, or 0 when unset. Valid values are 0 through 255; 0 disables this check.

--dry-run

Do not write changes to the config file, only display what would be changed

--exclude-repo repo

Do not update this repository. This option may be specified multiple times

-
--exclude-tag pattern

Ignore tags matching this glob pattern. This option may be specified multiple times.

+
--exclude-tag pattern

Ignore tags matching this glob pattern. This option may be specified multiple times. Defaults to update.exclude_tags in the project or global config when unset.

For example, use --exclude-tag nightly to skip a moving tag, or --exclude-tag '*-{alpha,beta,rc}*' to skip common prerelease tags.

--exit-code

Exit with status 1 if updates are available

--freeze

Store "frozen" hashes in rev instead of tag names. Defaults to update.freeze in the project or global config, or false when unset

--help, -h

Display the concise help for this command

-
--include-tag pattern

Only consider tags matching this glob pattern. This option may be specified multiple times.

+
--include-tag pattern

Only consider tags matching this glob pattern. This option may be specified multiple times. Defaults to update.include_tags in the project or global config when unset.

For example, use --include-tag 'v*' to only consider version tags and ignore tags such as nightly.

--jobs, -j jobs

Number of threads to use

[default: 0]

--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

@@ -588,10 +588,10 @@ prek update [OPTIONS]

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

--repo repo

Only update this repository. This option may be specified multiple times

-
--repo-exclude-tag repo=pattern

Ignore tags matching this glob pattern for a repository (<repo>=<pattern>). This option may be specified multiple times.

+
--repo-exclude-tag repo=pattern

Ignore tags matching this glob pattern for a repository (<repo>=<pattern>). This option may be specified multiple times. Adds to the effective update exclude filters for the named repository.

Repo-specific exclude filters are added to global --exclude-tag filters; matching either filter excludes the tag for that repository.

For example, use --repo-exclude-tag https://github.com/example/repo=nightly or --repo-exclude-tag https://github.com/example/repo=*-rc* to skip nightly or prerelease tags for one repository.

-
--repo-include-tag repo=pattern

Only consider tags matching this glob pattern for a repository (<repo>=<pattern>). This option may be specified multiple times.

+
--repo-include-tag repo=pattern

Only consider tags matching this glob pattern for a repository (<repo>=<pattern>). This option may be specified multiple times. Overrides the effective include filters for the named repository.

When set for a repository, this overrides any global --include-tag filters for that repository.

For example, use --repo-include-tag https://github.com/example/repo=v* to only consider version tags for one repository.

--verbose, -v

Use verbose output

diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 35f8b4570..4fb7f1a5e 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -11,40 +11,33 @@ This page documents the configuration keys that `prek` understands. This file stores user-level `prek` settings and does not define project hooks. -### Global `update.freeze` +### Global `update` -Whether [`prek update`](cli.md#prek-update) stores commit hashes in `rev` instead of tag names by default. +User-level defaults for [`prek update`](cli.md#prek-update): -- Type: boolean -- Default: `false` -- CLI override: [`prek update --freeze`](cli.md#prek-update--freeze) +| Key | Type | Default | CLI override | +| -- | -- | -- | -- | +| `update.cooldown_days` | integer days, `0` to `255` | `0` | [`--cooldown-days `](cli.md#prek-update) | +| `update.freeze` | boolean | `false` | [`--freeze`](cli.md#prek-update--freeze), which forces freezing on | +| `update.include_tags` | glob string or list of glob strings | empty | [`--include-tag`](cli.md#prek-update--include-tag) | +| `update.exclude_tags` | glob string or list of glob strings | empty | [`--exclude-tag`](cli.md#prek-update--exclude-tag) | ```toml [update] +cooldown_days = 7 freeze = true +include_tags = "v*" +exclude_tags = ["*-{alpha,beta,rc}*"] ``` -Project configs can also set [`update.freeze`](#updatefreeze). The effective precedence is: +Each field is resolved independently with this precedence: -1. [`prek update --freeze`](cli.md#prek-update--freeze), which forces freezing on -2. project config +1. the corresponding CLI option, when provided +2. [project `update`](#update) 3. user-level global config -4. default `false` - -### Global `update.cooldown_days` - -Default cooldown for [`prek update`](cli.md#prek-update). +4. the default shown above -- Type: integer days, `0` to `255` -- Default: `0` -- CLI override: [`prek update --cooldown-days `](cli.md#prek-update) - -```toml -[update] -cooldown_days = 7 -``` - -The age is computed from the tag creation timestamp for annotated tags, or from the tagged commit timestamp for lightweight tags. A value of `0` disables the cooldown check. +The cooldown age is computed from the tag creation timestamp for annotated tags, or from the tagged commit timestamp for lightweight tags. A value of `0` disables the cooldown check. !!! tip "Cooldowns never downgrade" @@ -52,14 +45,7 @@ The age is computed from the tag creation timestamp for annotated tags, or from !!! note "Compatibility alias" - The legacy `auto_update.cooldown_days` key is still accepted as an alias. - -Project configs can also set [`update.cooldown_days`](#updatecooldown_days). The effective precedence is: - -1. [`prek update --cooldown-days `](cli.md#prek-update) -2. project config -3. user-level global config -4. default `0` + The legacy `auto_update` key is still accepted as an alias for `update`. ## Top-level keys @@ -345,23 +331,36 @@ Allowed values: - `pre-merge-commit` - `pre-rebase` -### `update.cooldown_days` +### `update` !!! note "prek-only" This top-level key is a `prek` extension and is not recognized by upstream `pre-commit`. -Project default cooldown for [`prek update`](cli.md#prek-update). +Project settings for [`prek update`](cli.md#prek-update): -- Type: integer days, `0` to `255` -- Default: inherited from the user-level global config, or `0` -- CLI override: [`prek update --cooldown-days `](cli.md#prek-update) +| Key | Type | Default or behavior | +| -- | -- | -- | +| `update.cooldown_days` | integer days, `0` to `255` | Inherited from the global config, or `0`. | +| `update.freeze` | boolean | Inherited from the global config, or `false`. | +| `update.include_tags` | glob string or list of glob strings | Inherited from the global config, or empty. Only consider matching tags. | +| `update.exclude_tags` | glob string or list of glob strings | Inherited from the global config, or empty. Ignore matching tags. | +| `update.repos` | map from repo to tag-filter fields | Override tag filters for a repository whose configured `repo` value exactly matches the map key. | === "prek.toml" ```toml [update] cooldown_days = 7 + freeze = false + include_tags = "v*" + exclude_tags = ["*-{alpha,beta,rc}*"] + + [update.repos."https://github.com/example/hooks"] + include_tags = ["v1.*", "v2.*"] + + [update.repos."https://github.com/lycheeverse/lychee"] + exclude_tags = ["nightly", "*-rc*", "*-dev*"] ``` === ".pre-commit-config.yaml" @@ -369,41 +368,25 @@ Project default cooldown for [`prek update`](cli.md#prek-update). ```yaml update: cooldown_days: 7 + freeze: false + include_tags: "v*" + exclude_tags: ["*-{alpha,beta,rc}*"] + repos: + "https://github.com/example/hooks": + include_tags: ["v1.*", "v2.*"] + "https://github.com/lycheeverse/lychee": + exclude_tags: ["nightly", "*-rc*", "*-dev*"] ``` -!!! note "Compatibility alias" - - The legacy `auto_update.cooldown_days` key is still accepted as an alias. +Each project-level field overrides the corresponding [global `update`](#global-update) field. Within `update.repos`, `include_tags` and `exclude_tags` are resolved independently: an omitted field inherits the project default, a present field replaces it, and `[]` explicitly clears it. This allows one repository to override only `include_tags` while still inheriting `exclude_tags`. -In workspace mode, this setting is scoped to the project config file that defines it. It applies only to that project and is not inherited by nested projects. Sub-projects use their own `update` setting, then the user-level global config, then the default. If two projects use the same repo URL with different cooldown settings, [`prek update`](cli.md#prek-update) fetches the repo once but evaluates each project with its own cooldown. +CLI filters have the highest precedence. `--include-tag` and `--exclude-tag` replace the configured effective defaults; `--repo-include-tag` then replaces the include filters for its named repository, while `--repo-exclude-tag` adds excludes for its named repository. -### `update.freeze` +In workspace mode, `update` is scoped to the project config file that defines it and is not inherited by nested projects. Sub-projects use their own `update`, then the user-level global config, then built-in defaults. Repositories shared by multiple projects are fetched once but evaluated with each project's cooldown, freeze, and tag-filter settings. -!!! note "prek-only" - - This top-level key is a `prek` extension and is not recognized by upstream `pre-commit`. - -Whether [`prek update`](cli.md#prek-update) stores commit hashes in `rev` instead of tag names by default. - -- Type: boolean -- Default: inherited from the user-level global config, or `false` -- CLI override: [`prek update --freeze`](cli.md#prek-update--freeze), which forces freezing on - -=== "prek.toml" - - ```toml - [update] - freeze = true - ``` - -=== ".pre-commit-config.yaml" - - ```yaml - update: - freeze: true - ``` +!!! note "Compatibility alias" -In workspace mode, this setting is scoped to the project config file that defines it. It applies only to that project and is not inherited by nested projects. A project can set `freeze: false` to override a user-level `true`. If two projects use the same repo URL with different freeze settings, [`prek update`](cli.md#prek-update) fetches the repo once but writes each project's configured revision in the requested form. + The legacy `auto_update` key is still accepted as an alias for `update`. ### `minimum_prek_version` diff --git a/prek.schema.json b/prek.schema.json index 0b7ae09c0..12428d278 100644 --- a/prek.schema.json +++ b/prek.schema.json @@ -158,6 +158,44 @@ }, "freeze": { "type": "boolean" + }, + "include_tags": { + "$ref": "#/definitions/StringOrList" + }, + "exclude_tags": { + "$ref": "#/definitions/StringOrList" + }, + "repos": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/RepoTagFilterOptions" + } + } + } + }, + "StringOrList": { + "description": "A configuration value that accepts either one string or a list of strings.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "RepoTagFilterOptions": { + "description": "Overrides tag selection for one repository during `prek update`.", + "type": "object", + "properties": { + "include_tags": { + "$ref": "#/definitions/StringOrList" + }, + "exclude_tags": { + "$ref": "#/definitions/StringOrList" } } },