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::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::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::{CliTagFilterOptions, FilesystemOptions, TagFilterOptions};
use crate::store::Store;
use crate::warn_user;
use crate::workspace::{Project, Workspace};
mod config;
mod display;
mod repository;
mod source;
#[derive(Default, Clone)]
struct Revision {
rev: String,
frozen: Option<String>,
}
struct RepoUsage<'a> {
project: &'a Project,
remote_count: usize,
remote_index: usize,
rev_line_number: usize,
current_frozen: Option<String>,
current_frozen_site: Option<FrozenCommentSite>,
}
struct RepoTarget<'a> {
repo: &'a str,
current_rev: &'a str,
cooldown_days: u8,
freeze: bool,
tag_filters: TagFilters,
required_hook_ids: Vec<&'a str>,
usages: Vec<RepoUsage<'a>>,
}
struct RepoSource<'a> {
source: &'a str,
targets: Vec<RepoTarget<'a>>,
}
enum FrozenMismatchAction {
ReplaceWith(String),
Remove,
NoReplacement,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CommitPresence {
Present,
Absent,
Unknown,
}
enum FrozenMismatchReason {
ResolvesToDifferentCommit,
Unresolvable,
}
struct FrozenMismatch<'a> {
project: &'a Project,
remote_size: usize,
remote_index: usize,
rev_line_number: usize,
current_frozen: String,
frozen_site: Option<FrozenCommentSite>,
reason: FrozenMismatchReason,
current_rev_presence: CommitPresence,
action: FrozenMismatchAction,
}
#[derive(Clone)]
struct FrozenCommentSite {
line_number: usize,
source_line: String,
span: Range<usize>,
}
#[derive(Clone)]
struct FrozenRef {
line_number: usize,
current_frozen: Option<String>,
site: Option<FrozenCommentSite>,
}
#[derive(Clone)]
struct TagTimestamp {
tag: String,
version: Option<Version>,
timestamp: u64,
commit: String,
}
impl TagTimestamp {
fn new(tag: String, timestamp: u64, commit: String) -> Self {
let version = Version::parse(tag.strip_prefix('v').unwrap_or(&tag)).ok();
Self {
tag,
version,
timestamp,
commit,
}
}
}
#[derive(Debug, Eq, PartialEq)]
struct SkippedDowngrade {
current: String,
candidate: String,
cooldown_days: u8,
}
#[derive(Debug, Eq, PartialEq)]
enum RevisionSelection {
Unchanged,
Update(String),
SkippedDowngrade(SkippedDowngrade),
}
struct TagFilters {
include: GlobPatterns,
exclude: GlobPatterns,
}
impl TagFilters {
fn from_options(options: TagFilterOptions) -> Result<Self> {
Ok(Self {
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, tags: &'a [TagTimestamp]) -> Vec<&'a TagTimestamp> {
tags.iter()
.filter(|tag| self.is_included(&tag.tag) && !self.is_excluded(&tag.tag))
.collect()
}
fn is_included(&self, tag: &str) -> bool {
self.include.is_empty() || self.include.is_match(Path::new(tag))
}
fn is_excluded(&self, tag: &str) -> bool {
self.exclude.is_match(Path::new(tag))
}
}
fn group_repo_tag_patterns(values: Vec<RepoTagPattern>) -> BTreeMap<String, Vec<Glob>> {
let mut patterns_by_repo: BTreeMap<String, Vec<Glob>> = 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 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()))),
);
}
}
let missing_selectors = missing_selectors
.into_iter()
.map(|(_, selector)| selector)
.collect::<Vec<_>>();
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::<Vec<_>>();
(!repos.is_empty()).then_some((project.config_file(), repos))
})
.collect::<Vec<_>>();
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],
cli_tag_filters: &CliTagFilterOptions,
) {
let configured_repos = repo_sources
.iter()
.flat_map(|repo_source| repo_source.targets.iter().map(|target| target.repo))
.collect::<FxHashSet<_>>();
warn_missing_cli_repos(&configured_repos, filter_repos, cli_tag_filters);
warn_missing_project_repos(workspace);
}
struct ResolvedRepoUpdate<'a> {
revision: Revision,
skipped_downgrade: Option<SkippedDowngrade>,
frozen_mismatches: Vec<FrozenMismatch<'a>>,
}
struct RepoUpdate<'a> {
target: &'a RepoTarget<'a>,
result: Result<ResolvedRepoUpdate<'a>>,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
struct ProjectUpdateKey<'a> {
config_file: &'a Path,
}
impl<'a> ProjectUpdateKey<'a> {
fn config_file(self) -> &'a Path {
self.config_file
}
}
impl<'a> From<&'a Project> for ProjectUpdateKey<'a> {
fn from(project: &'a Project) -> Self {
Self {
config_file: project.config_file(),
}
}
}
type ProjectUpdates<'a> = FxHashMap<ProjectUpdateKey<'a>, Vec<Option<Revision>>>;
struct ApplyRepoUpdatesResult {
failure: bool,
has_updates: bool,
}
enum DisplayEventKind {
Update {
current: Revision,
next: Revision,
},
SkippedDowngrade {
current: String,
candidate: String,
cooldown_days: u8,
},
FrozenUpdate {
current: String,
next: String,
},
FrozenRemove {
current: String,
},
UpToDate {
current: Revision,
},
Failure {
error: String,
},
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum DisplayStream {
Stdout,
Stderr,
}
struct DisplayEvent<'a> {
stream: DisplayStream,
project: &'a Project,
repo: &'a str,
remote_index: usize,
line_number: usize,
kind: DisplayEventKind,
}
struct FrozenWarningEvent<'a> {
project: &'a Project,
repo: &'a str,
current_rev: &'a str,
remote_index: usize,
mismatch: &'a FrozenMismatch<'a>,
}
type RepoOccurrences<'a> = FxHashMap<(&'a Path, &'a str), usize>;
#[expect(clippy::fn_params_excessive_bools)]
pub(crate) async fn update(
store: &Store,
config: Option<PathBuf>,
filter_repos: Vec<String>,
exclude_repos: Vec<String>,
include_tag: Vec<Glob>,
exclude_tag: Vec<Glob>,
repo_include_tag: Vec<RepoTagPattern>,
repo_exclude_tag: Vec<RepoTagPattern>,
verbose: bool,
bleeding_edge: bool,
freeze: bool,
jobs: usize,
dry_run: bool,
exit_code: bool,
cooldown_days: Option<u8>,
filesystem: Option<FilesystemOptions>,
printer: Printer,
) -> Result<ExitStatus> {
let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?;
let selectors = Selectors::default();
let workspace = Workspace::discover(store, workspace_root, config, Some(&selectors), true)?;
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 {
jobs
};
let reporter = UpdateReporter::new(printer);
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))
&& !exclude_repos.iter().any(|repo| repo == target.repo)
});
}
repo_sources.retain(|repo_source| !repo_source.targets.is_empty());
let outcomes: Vec<RepoUpdate<'_>> = futures_util::stream::iter(&repo_sources)
.map(async |repo_source| {
let display_repo = repo_source
.targets
.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).await;
reporter.on_update_complete(progress);
result
})
.buffer_unordered(jobs)
.try_collect::<Vec<_>>()
.await?
.into_iter()
.flatten()
.collect();
reporter.on_complete();
warn_frozen_mismatches(&outcomes, printer)?;
let mut project_updates: ProjectUpdates<'_> = FxHashMap::default();
let apply_result =
apply_repo_updates(outcomes, verbose, dry_run, printer, &mut project_updates)?;
if !dry_run {
for (project, revisions) in project_updates {
if revisions.iter().any(Option::is_some) {
write_new_config(project.config_file(), &revisions).await?;
}
}
}
if apply_result.failure || (exit_code && apply_result.has_updates) {
return Ok(ExitStatus::Failure);
}
Ok(ExitStatus::Success)
}
#[cfg(test)]
mod tests {
use super::*;
fn tag(name: &str) -> TagTimestamp {
TagTimestamp::new(name.to_string(), 0, String::new())
}
fn filtered_tags(filters: &TagFilters, tags: &[TagTimestamp]) -> Vec<String> {
filters
.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 = tag_filters(&[], &[]);
let tags = [tag("v1.0.0"), tag("nightly")];
assert_eq!(filtered_tags(&filters, &tags), vec!["v1.0.0", "nightly"]);
}
#[test]
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, &tags), vec!["v1.1.0", "v2.1.0"]);
}
#[test]
fn tag_filters_apply_excludes_after_includes() {
let filters = tag_filters(&["v*"], &["v2.*", "*-rc*"]);
let tags = [
tag("v1.0.0"),
tag("v2.0.0"),
tag("v3.0.0-rc1"),
tag("nightly"),
];
assert_eq!(filtered_tags(&filters, &tags), vec!["v1.0.0"]);
}
}