From 922737d8fd9664a3ae4b1dba0c56d3c1996d3363 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:52:48 +0800 Subject: [PATCH] Preserve configured repo values for updates --- crates/prek/src/cli/cache_gc.rs | 9 +-- crates/prek/src/cli/update/mod.rs | 32 +++++--- crates/prek/src/cli/update/source.rs | 36 +++++---- crates/prek/src/config.rs | 86 +++++++++++++++++--- crates/prek/src/store.rs | 28 ++++--- crates/prek/src/workspace.rs | 25 +----- crates/prek/tests/cache.rs | 79 ++++++++++++++++++- crates/prek/tests/update.rs | 112 +++++++++++++++++++++++++++ 8 files changed, 329 insertions(+), 78 deletions(-) diff --git a/crates/prek/src/cli/cache_gc.rs b/crates/prek/src/cli/cache_gc.rs index 452dd97cb..bd47c40a2 100644 --- a/crates/prek/src/cli/cache_gc.rs +++ b/crates/prek/src/cli/cache_gc.rs @@ -373,18 +373,18 @@ fn hook_env_requirements_from_config( } let repo = match HookRepo::remote( - repo_config.repo.clone(), + repo_config.source().to_string(), repo_config.rev.clone(), repo_path, ) { Ok(repo) => repo, Err(err) => { - warn!(repo = %repo_config.repo, %err, "Failed to load repo manifest, skipping"); + warn!(repo = %repo_config.repo(), %err, "Failed to load repo manifest, skipping"); continue; } }; - let remote_repo = RepoIdentityRef::new(&repo_config.repo, &repo_config.rev); + let remote_repo = RepoIdentityRef::new(repo_config.source(), &repo_config.rev); for hook_config in &repo_config.hooks { let Some(manifest_hook) = repo.get_hook(&hook_config.id) else { @@ -765,9 +765,6 @@ struct RepoMarker { } fn read_repo_marker(root: &Path) -> Option { - // NOTE: `Store::clone_repo` serializes `RemoteRepo`, but with some fields skipped during - // serialization (e.g. `hooks`). That means deserializing back into `RemoteRepo` can fail. - // For GC display, we only need `repo` + `rev`. let content = fs_err::read_to_string(root.join(REPO_MARKER)).ok()?; serde_json::from_str(&content).ok() } diff --git a/crates/prek/src/cli/update/mod.rs b/crates/prek/src/cli/update/mod.rs index 2467985a1..7df4f200c 100644 --- a/crates/prek/src/cli/update/mod.rs +++ b/crates/prek/src/cli/update/mod.rs @@ -53,7 +53,7 @@ struct RepoUsage<'a> { /// One distinct `repo + rev + hook set + update settings` target that should be evaluated. struct RepoTarget<'a> { - /// The remote repository URL. + /// The repository value exactly as written in the configuration. repo: &'a str, /// The currently configured `rev` for this target. current_rev: &'a str, @@ -67,10 +67,10 @@ struct RepoTarget<'a> { usages: Vec>, } -/// One fetched remote repository URL with all configured revisions that use it. +/// One fetched remote repository source with all configured revisions that use it. struct RepoSource<'a> { - /// The remote repository URL. - repo: &'a str, + /// The resolved repository source used for fetch and cache identity. + source: &'a str, /// Distinct configured revisions that should be evaluated against this fetched repo. targets: Vec>, } @@ -280,7 +280,7 @@ fn warn_missing_repos( ) { let configured_repos = repo_sources .iter() - .map(|repo_source| repo_source.repo) + .flat_map(|repo_source| repo_source.targets.iter().map(|target| target.repo)) .collect::>(); let mut missing_repos = filter_repos .iter() @@ -432,16 +432,24 @@ pub(crate) async fn update( }; let reporter = UpdateReporter::new(printer); - let repo_sources = + let mut repo_sources = collect_repo_sources(&workspace, freeze, cooldown_days, filesystem.as_ref())?; warn_missing_repos(&repo_sources, &filter_repos, &tag_filters); - let sources = repo_sources.iter().filter(|repo_source| { - (filter_repos.is_empty() || filter_repos.iter().any(|repo| repo == repo_source.repo)) - && !exclude_repos.iter().any(|repo| repo == repo_source.repo) - }); - let outcomes: Vec> = futures_util::stream::iter(sources) + 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> = futures_util::stream::iter(&repo_sources) .map(async |repo_source| { - let progress = reporter.on_update_start(repo_source.repo); + 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, &tag_filters).await; reporter.on_update_complete(progress); result diff --git a/crates/prek/src/cli/update/source.rs b/crates/prek/src/cli/update/source.rs index 8f275eff5..3c229c14e 100644 --- a/crates/prek/src/cli/update/source.rs +++ b/crates/prek/src/cli/update/source.rs @@ -24,6 +24,7 @@ use crate::workspace::Workspace; /// Identifies repo usages that can share one update evaluation. #[derive(Eq, Hash, PartialEq)] struct RepoTargetKey<'a> { + repo: &'a str, current_rev: &'a str, required_hook_ids: Vec<&'a str>, cooldown_days: u8, @@ -31,16 +32,16 @@ struct RepoTargetKey<'a> { } type RepoTargetsByKey<'a> = FxHashMap, RepoTarget<'a>>; -type RepoSourcesByRepo<'a> = FxHashMap<&'a str, RepoTargetsByKey<'a>>; +type RepoSourcesBySource<'a> = FxHashMap<&'a str, RepoTargetsByKey<'a>>; -/// Collects configured remote repos grouped by fetch source, revision, hooks, and settings. +/// Collects configured remote repos grouped by source, configured value, revision, and settings. pub(super) fn collect_repo_sources<'a>( workspace: &'a Workspace, cli_freeze: bool, cli_cooldown_days: Option, filesystem: Option<&FilesystemOptions>, ) -> Result>> { - let mut repo_sources: RepoSourcesByRepo<'a> = FxHashMap::default(); + let mut repo_sources: RepoSourcesBySource<'a> = FxHashMap::default(); for project in workspace.projects() { let project_update = project.config().update.as_ref(); @@ -85,8 +86,9 @@ pub(super) fn collect_repo_sources<'a>( required_hook_ids.sort_unstable(); required_hook_ids.dedup(); - let targets = repo_sources.entry(remote_repo.repo.as_str()).or_default(); + let targets = repo_sources.entry(remote_repo.source()).or_default(); let target_key = RepoTargetKey { + repo: remote_repo.repo(), current_rev: remote_repo.rev.as_str(), required_hook_ids, cooldown_days, @@ -97,7 +99,7 @@ pub(super) fn collect_repo_sources<'a>( Entry::Vacant(entry) => { let required_hook_ids = entry.key().required_hook_ids.clone(); entry.insert(RepoTarget { - repo: remote_repo.repo.as_str(), + repo: remote_repo.repo(), current_rev: remote_repo.rev.as_str(), cooldown_days, freeze, @@ -120,16 +122,17 @@ pub(super) fn collect_repo_sources<'a>( Ok(repo_sources .into_iter() - .map(|(repo, targets)| { + .map(|(source, targets)| { let mut targets = targets.into_values().collect::>(); targets.sort_by(|a, b| { - a.current_rev - .cmp(b.current_rev) + 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 { repo, targets } + RepoSource { source, targets } }) .collect()) } @@ -213,10 +216,10 @@ pub(super) async fn evaluate_repo_source<'a>( let result = async { trace!( "Cloning repository `{}` to `{}`", - repo_source.repo, + repo_source.source, repo_path.display() ); - setup_and_fetch_repo(repo_source.repo, repo_path).await?; + setup_and_fetch_repo(repo_source.source, repo_path).await?; let metadata = list_tag_metadata(repo_path).await?; anyhow::Ok(metadata) @@ -238,14 +241,13 @@ pub(super) async fn evaluate_repo_source<'a>( } }; - let update_tag_timestamps = tag_filters - .filter(repo_source.repo, &tag_timestamps) - .into_iter() - .cloned() - .collect::>(); - 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) + .into_iter() + .cloned() + .collect::>(); let result = evaluate_repo_target( repo_path, target, diff --git a/crates/prek/src/config.rs b/crates/prek/src/config.rs index 05715ec23..e54cd2e9c 100644 --- a/crates/prek/src/config.rs +++ b/crates/prek/src/config.rs @@ -884,9 +884,11 @@ impl TryFrom for BuiltinHook { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub(crate) struct RemoteRepo { - pub repo: String, + repo: String, + #[serde(skip)] + resolved_source: Option, pub rev: String, #[serde(skip_serializing)] pub hooks: Vec, @@ -897,13 +899,13 @@ pub(crate) struct RemoteRepo { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct RemoteRepoKey<'a> { - repo: &'a str, + source: &'a str, rev: &'a str, } impl<'a> RemoteRepoKey<'a> { - pub(crate) fn repo(self) -> &'a str { - self.repo + pub(crate) fn source(self) -> &'a str { + self.source } pub(crate) fn rev(self) -> &'a str { @@ -915,23 +917,53 @@ impl RemoteRepo { pub fn new(repo: String, rev: String, hooks: Vec) -> Self { Self { repo, + resolved_source: None, rev, hooks, _unused_keys: BTreeMap::new(), } } + /// The repository value exactly as written in the configuration. + pub(crate) fn repo(&self) -> &str { + &self.repo + } + + /// The repository source used for fetch and cache identity. + pub(crate) fn source(&self) -> &str { + self.resolved_source.as_deref().unwrap_or(&self.repo) + } + + pub(crate) fn set_resolved_source(&mut self, source: String) { + self.resolved_source = Some(source); + } + pub fn key(&self) -> RemoteRepoKey<'_> { RemoteRepoKey { - repo: &self.repo, + source: self.source(), rev: &self.rev, } } } +impl std::fmt::Debug for RemoteRepo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut debug = f.debug_struct("RemoteRepo"); + debug.field("repo", &self.repo); + if let Some(source) = &self.resolved_source { + debug.field("source", source); + } + debug + .field("rev", &self.rev) + .field("hooks", &self.hooks) + .field("_unused_keys", &self._unused_keys) + .finish() + } +} + impl Display for RemoteRepo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}@{}", self.repo, self.rev) + write!(f, "{}@{}", self.repo(), self.rev) } } @@ -1109,6 +1141,7 @@ impl<'de> Deserialize<'de> for Repo { }; Ok(Repo::Remote(RemoteRepo { repo: repo_value, + resolved_source: None, rev, hooks, _unused_keys: unused, @@ -1193,6 +1226,40 @@ pub(crate) struct Config { _unused_keys: BTreeMap, } +impl Config { + /// Resolve local relative repository sources from the config file, not the process cwd. + fn resolve_relative_repo_sources(&mut self, config_path: &Path) -> Result<(), Error> { + let config_dir = config_path + .parent() + .expect("config file must have a parent"); + for repo in &mut self.repos { + let Repo::Remote(remote) = repo else { + continue; + }; + + let configured_repo = remote.repo(); + let repo_path = Path::new(configured_repo); + if configured_repo.starts_with("http://") + || configured_repo.starts_with("https://") + || !repo_path.is_relative() + { + continue; + } + + let resolved = config_dir.join(repo_path); + if resolved.is_dir() { + remote.set_resolved_source( + dunce::canonicalize(resolved)? + .to_string_lossy() + .into_owned(), + ); + } + } + + Ok(()) + } +} + #[derive(Debug, thiserror::Error)] pub(crate) enum Error { #[error(transparent)] @@ -1319,12 +1386,13 @@ fn warn_unused_paths(path: &Path, entries: &[String]) { pub(crate) fn load_config(path: &Path) -> Result { let content = fs_err::read_to_string(path)?; - let config = match path.extension() { + let mut config: Config = match path.extension() { Some(ext) if ext.eq_ignore_ascii_case("toml") => toml::from_str(&content) .map_err(|e| Error::Toml(path.user_display().to_string(), Box::new(e)))?, _ => serde_saphyr::from_str(&content) .map_err(|e| Error::Yaml(path.user_display().to_string(), Box::new(e)))?, }; + config.resolve_relative_repo_sources(path)?; Ok(config) } @@ -1355,7 +1423,7 @@ pub(crate) fn read_config(path: &Path) -> Result { if !repos_has_mutable_rev.is_empty() { let msg = repos_has_mutable_rev .iter() - .map(|repo| format!("{}: {}", repo.repo.cyan(), repo.rev.yellow())) + .map(|repo| format!("{}: {}", repo.repo().cyan(), repo.rev.yellow())) .join("\n"); warn_user!( diff --git a/crates/prek/src/store.rs b/crates/prek/src/store.rs index 0a94fba48..11a0d3885 100644 --- a/crates/prek/src/store.rs +++ b/crates/prek/src/store.rs @@ -22,6 +22,12 @@ struct PendingClone<'a> { repo: &'a RemoteRepo, } +#[derive(serde::Serialize)] +struct RepoMarker<'a> { + repo: &'a str, + rev: &'a str, +} + enum FirstClonePass<'a> { Ready { repo: &'a RemoteRepo, @@ -116,7 +122,7 @@ impl Store { ?terminal_prompt, "Cloning repo" ); - git::clone_repo(&repo.repo, &repo.rev, temp.path(), terminal_prompt).await?; + git::clone_repo(repo.source(), &repo.rev, temp.path(), terminal_prompt).await?; Ok(temp) } @@ -131,7 +137,11 @@ impl Store { fs_err::tokio::remove_dir_all(&target).await.ok(); fs_err::tokio::rename(temp, &target).await?; - let content = serde_json::to_string_pretty(&repo)?; + let marker = RepoMarker { + repo: repo.source(), + rev: &repo.rev, + }; + let content = serde_json::to_string_pretty(&marker)?; fs_err::tokio::write(target.join(REPO_MARKER), content).await?; Ok(target) @@ -176,7 +186,7 @@ impl Store { }), Err(err) if git::is_auth_error(&err) => { warn!( - repo = %pending.repo.repo, + repo = %pending.repo.repo(), ?err, "Clone failed with authentication error and terminal prompts disabled" ); @@ -187,7 +197,7 @@ impl Store { }) } Err(err) => Err(Error::CloneRepo { - repo: pending.repo.repo.clone(), + repo: pending.repo.repo().to_string(), error: err, }), } @@ -225,7 +235,7 @@ impl Store { // failure instead of attempting the prompt-enabled retry path. if let Some((repo, error)) = auth_failed.into_iter().next() { return Err(Error::CloneRepo { - repo: repo.repo.clone(), + repo: repo.repo().to_string(), error, }); } @@ -242,13 +252,13 @@ impl Store { for (repo, _error) in auth_failed { warn_user!( "Authentication may be required to clone repository `{}`. Retrying with terminal prompts enabled.", - repo.repo + repo.repo() ); let temp = self .clone_repo_to_temp(repo, TerminalPrompt::Enabled) .await .map_err(|error| Error::CloneRepo { - repo: repo.repo.clone(), + repo: repo.repo().to_string(), error, })?; let path = self.persist_cloned_repo(repo, temp).await?; @@ -270,7 +280,7 @@ impl Store { .get(&repo_key) .cloned() .ok_or_else(|| Error::CloneRepo { - repo: repo.repo.clone(), + repo: repo.repo().to_string(), error: git::Error::Io(std::io::Error::other("repo was not cloned")), }) } @@ -287,7 +297,7 @@ impl Store { /// Returns the store key (directory name) for a remote repo. pub(crate) fn repo_key(repo: &RemoteRepo) -> String { let mut hasher = SeaHasher::new(); - repo.repo.hash(&mut hasher); + repo.source().hash(&mut hasher); repo.rev.hash(&mut hasher); to_hex(hasher.finish()) } diff --git a/crates/prek/src/workspace.rs b/crates/prek/src/workspace.rs index bea1aaa7d..a31749393 100644 --- a/crates/prek/src/workspace.rs +++ b/crates/prek/src/workspace.rs @@ -179,7 +179,7 @@ async fn init_remote_repos<'a>( .into_iter() .map(|(key, path)| { let repo = Arc::new(Repo::remote( - key.repo().to_string(), + key.source().to_string(), key.rev().to_string(), path, )?); @@ -263,33 +263,12 @@ impl Project { "Loading project configuration" ); - let mut config = read_config(&config_path)?; + let config = read_config(&config_path)?; let config_dir = config_path .parent() .expect("config file must have a parent"); - // Resolve relative repo paths against the config file's directory. - // This ensures paths like `../hook-repo` are resolved from where the - // config file lives, not from the process's current working directory. - for repo in &mut config.repos { - if let config::Repo::Remote(remote) = repo { - let repo_path = Path::new(&remote.repo); - if !remote.repo.starts_with("http://") - && !remote.repo.starts_with("https://") - && repo_path.is_relative() - { - let resolved = config_dir.join(repo_path); - if resolved.is_dir() { - remote.repo = dunce::canonicalize(resolved) - .map_err(config::Error::from)? - .to_string_lossy() - .into_owned(); - } - } - } - } - let root = root.unwrap_or_else(|| config_dir.to_path_buf()); Ok(Self { diff --git a/crates/prek/tests/cache.rs b/crates/prek/tests/cache.rs index 3d226b291..e6e635058 100644 --- a/crates/prek/tests/cache.rs +++ b/crates/prek/tests/cache.rs @@ -1,11 +1,12 @@ +use assert_cmd::assert::OutputAssertExt; use assert_fs::assert::PathAssert; use assert_fs::fixture::{ChildPath, PathChild, PathCreateDir}; use assert_fs::prelude::FileWriteStr; -use prek_consts::PRE_COMMIT_CONFIG_YAML; +use prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_HOOKS_YAML}; use serde_json::json; use std::time::{Duration, SystemTime}; -use crate::common::{TestContext, cmd_snapshot}; +use crate::common::{TestContext, cmd_snapshot, git_cmd}; mod common; @@ -309,6 +310,80 @@ fn cache_gc_removes_unreferenced_entries() -> anyhow::Result<()> { Ok(()) } +#[test] +fn cache_gc_keeps_relative_remote_repo() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + + let hook_repo = context.work_dir().child("hook-repo"); + hook_repo.create_dir_all()?; + git_cmd(&hook_repo).args(["init"]).assert().success(); + hook_repo + .child(PRE_COMMIT_HOOKS_YAML) + .write_str(indoc::indoc! {r" + - id: test-hook + name: Test Hook + entry: echo test + language: system + always_run: true + "})?; + git_cmd(&hook_repo).args(["add", "."]).assert().success(); + git_cmd(&hook_repo) + .args(["commit", "-m", "Initial commit"]) + .assert() + .success(); + let output = git_cmd(&hook_repo).args(["rev-parse", "HEAD"]).output()?; + let revision = String::from_utf8(output.stdout)?.trim().to_string(); + + let subproject = context.work_dir().child("subproject"); + subproject.create_dir_all()?; + subproject + .child(PRE_COMMIT_CONFIG_YAML) + .write_str(&indoc::formatdoc! {r" + repos: + - repo: ../hook-repo + rev: {revision} + hooks: + - id: test-hook + "})?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run() + .arg("--config") + .arg("subproject/.pre-commit-config.yaml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Test Hook................................................................Passed + + ----- stderr ----- + "); + + let repos_dir = context.home_dir().child("repos"); + let cached_repo = fs_err::read_dir(repos_dir.path())? + .next() + .transpose()? + .expect("expected the relative remote repo to be cached") + .path(); + repos_dir.child("unused-repo").create_dir_all()?; + + cmd_snapshot!(context.filters(), context.command().args(["cache", "gc"]), @r" + success: true + exit_code: 0 + ----- stdout ----- + Removed 1 repo ([SIZE]) + + ----- stderr ----- + "); + + assert!(cached_repo.is_dir(), "cache GC removed the configured repo"); + repos_dir + .child("unused-repo") + .assert(predicates::path::missing()); + + Ok(()) +} + #[test] fn cache_gc_prunes_unused_tool_versions() -> anyhow::Result<()> { let context = TestContext::new(); diff --git a/crates/prek/tests/update.rs b/crates/prek/tests/update.rs index f529f23fc..83875e51f 100644 --- a/crates/prek/tests/update.rs +++ b/crates/prek/tests/update.rs @@ -645,6 +645,68 @@ fn update_warns_for_missing_repos() -> Result<()> { Ok(()) } +#[test] +fn update_repo_options_match_relative_config_value() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let selected_path = create_local_git_repo( + &context, + "relative-selected", + &["v1.0.0", "v1.1.0", "v1.2.0", "v2.0.0"], + )?; + + let selected_repo = "../home/test-repos/relative-selected"; + context.write_pre_commit_config(&indoc::formatdoc! {r" + repos: + - repo: {selected_repo} + rev: v1.0.0 + hooks: + - id: test-hook + - repo: {selected_path} + rev: v1.0.0 + hooks: + - id: test-hook + "}); + context.git_add("."); + + let filters = context.filters(); + let include_filter = format!("{selected_repo}=v1.*"); + let exclude_filter = format!("{selected_repo}=v1.2.0"); + cmd_snapshot!(filters.clone(), context.update() + .arg("--repo").arg(selected_repo) + .arg("--repo-include-tag").arg(include_filter) + .arg("--repo-exclude-tag").arg(exclude_filter) + .arg("--cooldown-days").arg("0"), @r" + success: true + exit_code: 0 + ----- stdout ----- + ../home/test-repos/relative-selected + updating rev `v1.0.0` -> `v1.1.0` + + ----- stderr ----- + "); + + insta::with_settings!( + { filters => filters }, + { + assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r" + repos: + - repo: ../home/test-repos/relative-selected + rev: v1.1.0 + hooks: + - id: test-hook + - repo: [HOME]/test-repos/relative-selected + rev: v1.0.0 + hooks: + - id: test-hook + "); + } + ); + + Ok(()) +} + #[test] fn update_exclude_repo_skips_fetching_repo() -> Result<()> { let context = TestContext::new(); @@ -703,6 +765,56 @@ fn update_exclude_repo_skips_fetching_repo() -> Result<()> { Ok(()) } +#[test] +fn update_exclude_repo_matches_relative_config_value() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + create_local_git_repo(&context, "relative-excluded", &["v1.0.0", "v2.0.0"])?; + create_local_git_repo(&context, "relative-included", &["v1.0.0", "v2.0.0"])?; + + let excluded_repo = "../home/test-repos/relative-excluded"; + let included_repo = "../home/test-repos/relative-included"; + context.write_pre_commit_config(&indoc::formatdoc! {r" + repos: + - repo: {excluded_repo} + rev: v1.0.0 + hooks: + - id: test-hook + - repo: {included_repo} + rev: v1.0.0 + hooks: + - id: test-hook + "}); + context.git_add("."); + + cmd_snapshot!(context.filters(), context.update() + .arg("--exclude-repo").arg(excluded_repo) + .arg("--cooldown-days").arg("0"), @r" + success: true + exit_code: 0 + ----- stdout ----- + ../home/test-repos/relative-included + updating rev `v1.0.0` -> `v2.0.0` + + ----- stderr ----- + "); + + assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r" + repos: + - repo: ../home/test-repos/relative-excluded + rev: v1.0.0 + hooks: + - id: test-hook + - repo: ../home/test-repos/relative-included + rev: v2.0.0 + hooks: + - id: test-hook + "); + + Ok(()) +} + #[test] fn update_tag_filters_include_then_exclude() -> Result<()> { let context = TestContext::new();