Skip to content

Commit 61cbcbf

Browse files
authored
Preserve additional dependency order (#2311)
Preserve the user-specified order and duplicates of hook `additional_dependencies` for installation and environment reuse. Hook environment markers now use schema version 1 and store remote repository identity as structured `{ url, rev }` data. Legacy markers are invalidated, and cache matching distinguishes dependency order, duplicates, and repository identity. Closes #1602 Partially supersedes #1603
1 parent 4b006eb commit 61cbcbf

30 files changed

Lines changed: 486 additions & 324 deletions

crates/prek/src/cli/cache_gc.rs

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::borrow::Cow;
12
use std::fmt::Write;
23
use std::fmt::{Display, Formatter};
34
use std::ops::AddAssign;
@@ -15,7 +16,9 @@ use crate::cli::ExitStatus;
1516
use crate::cli::cache_size::{dir_size_bytes, human_readable_bytes};
1617
use crate::cli::run::InstallCache;
1718
use crate::config::{self, Error as ConfigError, Repo as ConfigRepo, load_config};
18-
use crate::hook::{HOOK_MARKER, HookEnvKey, HookSpec, InstallInfo, Repo as HookRepo};
19+
use crate::hook::{
20+
HOOK_MARKER, HookEnvRequirement, HookSpec, InstallInfo, Repo as HookRepo, RepoIdentityRef,
21+
};
1922
use crate::printer::Printer;
2023
use crate::store::{CacheBucket, REPO_MARKER, Store, ToolBucket};
2124

@@ -160,7 +163,7 @@ pub(crate) async fn cache_gc(
160163
let mut used_tools: FxHashSet<ToolBucket> = FxHashSet::default();
161164
let mut used_tool_versions: FxHashMap<ToolBucket, FxHashSet<String>> = FxHashMap::default();
162165
let mut used_cache: FxHashSet<CacheBucket> = FxHashSet::default();
163-
let mut used_env_keys: Vec<HookEnvKey> = Vec::new();
166+
let mut used_env_requirements: Vec<HookEnvRequirement> = Vec::new();
164167

165168
// Always keep Prek's own cache.
166169
used_cache.insert(CacheBucket::Prek);
@@ -187,7 +190,7 @@ pub(crate) async fn cache_gc(
187190
};
188191
kept_configs.insert(config_path);
189192

190-
used_env_keys.extend(hook_env_keys_from_config(store, &config));
193+
used_env_requirements.extend(hook_env_requirements_from_config(store, &config));
191194

192195
// Mark repos referenced by this config (if present in store).
193196
// We do this via config parsing (no clone), so GC won't keep repos for missing configs.
@@ -206,17 +209,20 @@ pub(crate) async fn cache_gc(
206209
}
207210

208211
// Mark tools/caches from hook languages.
209-
for key in &used_env_keys {
210-
used_tools.extend(key.language.tool_buckets());
211-
used_cache.extend(key.language.cache_buckets());
212+
for requirement in &used_env_requirements {
213+
used_tools.extend(requirement.language.tool_buckets());
214+
used_cache.extend(requirement.language.cache_buckets());
212215
}
213216

214217
// Mark hook environments by matching already-installed env metadata.
215218
// While doing this, try to derive the specific tool *version* directories in use from
216219
// `InstallInfo.toolchain` (which is persisted in `.prek-hook.json`).
217220
for installed in install_cache.installed_hooks(store).await {
218221
let info = installed.info_ref();
219-
if used_env_keys.iter().any(|k| k.matches_install_info(info)) {
222+
if used_env_requirements
223+
.iter()
224+
.any(|requirement| requirement.as_ref().is_satisfied_by(info))
225+
{
220226
if let Some(dir) = info
221227
.env_path
222228
.file_name()
@@ -352,8 +358,11 @@ fn print_removed_details(printer: Printer, verb: &str, removal: &Removal) -> Res
352358
Ok(())
353359
}
354360

355-
fn hook_env_keys_from_config(store: &Store, config: &config::Config) -> Vec<HookEnvKey> {
356-
let mut keys = Vec::new();
361+
fn hook_env_requirements_from_config(
362+
store: &Store,
363+
config: &config::Config,
364+
) -> Vec<HookEnvRequirement> {
365+
let mut requirements = Vec::new();
357366

358367
for repo_config in &config.repos {
359368
match repo_config {
@@ -375,7 +384,7 @@ fn hook_env_keys_from_config(store: &Store, config: &config::Config) -> Vec<Hook
375384
}
376385
};
377386

378-
let remote_dep = repo_config.to_string();
387+
let remote_repo = RepoIdentityRef::new(&repo_config.repo, &repo_config.rev);
379388

380389
for hook_config in &repo_config.hooks {
381390
let Some(manifest_hook) = repo.get_hook(&hook_config.id) else {
@@ -385,23 +394,23 @@ fn hook_env_keys_from_config(store: &Store, config: &config::Config) -> Vec<Hook
385394
let mut hook_spec = manifest_hook.clone();
386395
hook_spec.apply_remote_hook_overrides(hook_config);
387396

388-
match HookEnvKey::from_hook_spec(config, hook_spec, Some(&remote_dep)) {
389-
Ok(Some(key)) => keys.push(key),
397+
match HookEnvRequirement::from_hook_spec(config, hook_spec, Some(remote_repo)) {
398+
Ok(Some(requirement)) => requirements.push(requirement),
390399
Ok(None) => {}
391400
Err(err) => {
392-
warn!(hook = %hook_config.id, repo = %remote_dep, %err, "Failed to compute hook env key, skipping");
401+
warn!(hook = %hook_config.id, repo = %remote_repo, %err, "Failed to compute hook environment requirement, skipping");
393402
}
394403
}
395404
}
396405
}
397406
ConfigRepo::Local(repo_config) => {
398407
for hook in &repo_config.hooks {
399408
let hook_spec = HookSpec::from(hook.clone());
400-
match HookEnvKey::from_hook_spec(config, hook_spec, None) {
401-
Ok(Some(key)) => keys.push(key),
409+
match HookEnvRequirement::from_hook_spec(config, hook_spec, None) {
410+
Ok(Some(requirement)) => requirements.push(requirement),
402411
Ok(None) => {}
403412
Err(err) => {
404-
warn!(hook = %hook.id, %err, "Failed to compute hook env key, skipping");
413+
warn!(hook = %hook.id, %err, "Failed to compute hook environment requirement, skipping");
405414
}
406415
}
407416
}
@@ -410,7 +419,7 @@ fn hook_env_keys_from_config(store: &Store, config: &config::Config) -> Vec<Hook
410419
}
411420
}
412421

413-
keys
422+
requirements
414423
}
415424

416425
fn mark_tool_versions_from_install_info(
@@ -731,7 +740,7 @@ fn detail_lines_for_entry(
731740
info.language_version
732741
));
733742

734-
let (repo_dep, deps) = split_repo_dependency(&info.dependencies);
743+
let (repo_dep, deps) = marker_repo_and_dependencies(info);
735744
if let Some(repo_dep) = repo_dep {
736745
lines.push(format!(
737746
"{}: {}",
@@ -780,8 +789,8 @@ fn truncate_end(s: &str, max_chars: usize) -> String {
780789
out
781790
}
782791

783-
fn split_repo_dependency(deps: &FxHashSet<String>) -> (Option<String>, Vec<String>) {
784-
// Best-effort: the remote repo dependency is typically `repo@rev`.
792+
fn split_repo_dependency(deps: &[String]) -> (Option<String>, Vec<String>) {
793+
// Legacy markers stored the remote repo identity as a `repo@rev` dependency.
785794
// Prefer URL-like values to avoid accidentally treating PEP508 deps as repo identifiers.
786795
let mut repo_dep: Option<String> = None;
787796
let mut rest = Vec::new();
@@ -804,6 +813,18 @@ fn split_repo_dependency(deps: &FxHashSet<String>) -> (Option<String>, Vec<Strin
804813
(repo_dep, rest)
805814
}
806815

816+
fn marker_repo_and_dependencies(info: &InstallInfo) -> (Option<String>, Cow<'_, [String]>) {
817+
if info.schema_version() == 0 {
818+
let (repo, dependencies) = split_repo_dependency(&info.dependencies);
819+
(repo, Cow::Owned(dependencies))
820+
} else {
821+
(
822+
info.repo().map(|repo| repo.to_string()),
823+
Cow::Borrowed(&info.dependencies),
824+
)
825+
}
826+
}
827+
807828
fn format_dependency_list(deps: &[String], max_items: usize, max_chars: usize) -> String {
808829
if deps.is_empty() {
809830
return String::new();
@@ -843,10 +864,11 @@ mod tests {
843864

844865
#[test]
845866
fn split_repo_dependency_prefers_url_like_repo_at_rev() {
846-
let mut deps = FxHashSet::default();
847-
deps.insert("requests==2.32.0".to_string());
848-
deps.insert("black==24.1.0".to_string());
849-
deps.insert("https://github.com/pre-commit/pre-commit-hooks@v1.0.0".to_string());
867+
let deps = vec![
868+
"requests==2.32.0".to_string(),
869+
"black==24.1.0".to_string(),
870+
"https://github.com/pre-commit/pre-commit-hooks@v1.0.0".to_string(),
871+
];
850872

851873
let (repo_dep, rest) = split_repo_dependency(&deps);
852874

@@ -859,15 +881,39 @@ mod tests {
859881

860882
#[test]
861883
fn split_repo_dependency_returns_none_when_no_repo_like_dep() {
862-
let mut deps = FxHashSet::default();
863-
deps.insert("requests==2.32.0".to_string());
864-
deps.insert("black==24.1.0".to_string());
884+
let deps = vec!["requests==2.32.0".to_string(), "black==24.1.0".to_string()];
865885

866886
let (repo_dep, rest) = split_repo_dependency(&deps);
867887
assert!(repo_dep.is_none());
868888
assert_eq!(rest, vec!["black==24.1.0", "requests==2.32.0"]);
869889
}
870890

891+
#[test]
892+
fn marker_repo_and_dependencies_uses_structured_repo() {
893+
let info: InstallInfo = serde_json::from_value(serde_json::json!({
894+
"schema_version": 1,
895+
"language": "python",
896+
"language_version": "3.12.0",
897+
"repo": {
898+
"url": "https://example.com/repo",
899+
"rev": "v1.0.0",
900+
},
901+
"dependencies": ["https://example.com/dependency@v2.0.0"],
902+
"env_path": "/tmp/hook-env",
903+
"toolchain": "/usr/bin/python3",
904+
"extra": {},
905+
}))
906+
.expect("deserialize install info");
907+
908+
let (repo, dependencies) = marker_repo_and_dependencies(&info);
909+
910+
assert_eq!(repo.as_deref(), Some("https://example.com/repo@v1.0.0"));
911+
assert_eq!(
912+
dependencies.as_ref(),
913+
&["https://example.com/dependency@v2.0.0".to_string()]
914+
);
915+
}
916+
871917
#[test]
872918
fn format_dependency_list_includes_more_suffix() {
873919
let deps = vec!["a".to_string(), "b".to_string(), "c".to_string()];

crates/prek/src/cli/run/install.rs

Lines changed: 44 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ use anyhow::{Context, Result};
55
use futures_util::stream::{FuturesUnordered, StreamExt};
66
use mea::once::OnceCell;
77
use mea::semaphore::Semaphore;
8-
use rustc_hash::FxHashMap;
98
use tracing::{debug, warn};
109

1110
use crate::cli::reporter::HookInstallReporter;
@@ -73,17 +72,24 @@ async fn install_partition(
7372
for hook in hooks {
7473
debug_assert!(hook.needs_install_env());
7574

76-
let installed_hook = if let Some(info) = installed_hooks.iter().find_map(|installed| {
77-
let InstalledHook::Installed { info, .. } = installed else {
78-
return None;
79-
};
80-
info.matches(&hook).then(|| info.clone())
81-
}) {
75+
let reusable_info = hook.environment_requirement().and_then(|requirement| {
76+
installed_hooks.iter().find_map(|installed| {
77+
let InstalledHook::Installed { info, .. } = installed else {
78+
return None;
79+
};
80+
requirement.is_satisfied_by(info).then_some(info)
81+
})
82+
});
83+
84+
let installed_hook = if let Some(info) = reusable_info {
8285
debug!(
8386
"Found installed environment for hook `{hook}` at `{}`",
8487
info.env_path.display()
8588
);
86-
InstalledHook::Installed { hook, info }
89+
InstalledHook::Installed {
90+
hook,
91+
info: Arc::clone(info),
92+
}
8793
} else {
8894
let _permit = semaphore.acquire(1).await;
8995

@@ -117,43 +123,41 @@ async fn install_partition(
117123

118124
/// Group hooks so each partition can install independently.
119125
///
120-
/// Different languages can install concurrently. Hooks with the same language and dependency set
121-
/// stay in one partition so later hooks can reuse an environment installed by an earlier hook.
126+
/// Hooks with the same install language, repository, and dependency sequence stay in one
127+
/// partition so later hooks can reuse an environment installed by an earlier hook. Version
128+
/// requirements are checked by the full environment requirement and intentionally do not split
129+
/// partitions.
122130
fn partition_hooks(hooks: Vec<Arc<Hook>>) -> Vec<Vec<Arc<Hook>>> {
123-
let mut hooks_by_language = FxHashMap::default();
131+
let mut partitions: Vec<Vec<Arc<Hook>>> = Vec::new();
124132
for hook in hooks {
125-
// `pygrep` hooks use Python installation and can share Python environments.
126-
let language = if hook.language == Language::Pygrep {
127-
Language::Python
133+
if let Some(partition) = partitions
134+
.iter_mut()
135+
.find(|partition| same_install_partition(&partition[0], &hook))
136+
{
137+
partition.push(hook);
128138
} else {
129-
hook.language
130-
};
131-
hooks_by_language
132-
.entry(language)
133-
.or_insert_with(Vec::new)
134-
.push(hook);
135-
}
136-
137-
let mut partitions = Vec::new();
138-
for (_, hooks) in hooks_by_language {
139-
let mut groups: Vec<Vec<Arc<Hook>>> = Vec::new();
140-
for hook in hooks {
141-
let group_index = groups
142-
.iter()
143-
.position(|group| group[0].env_key_dependencies() == hook.env_key_dependencies());
144-
145-
if let Some(index) = group_index {
146-
groups[index].push(hook);
147-
} else {
148-
groups.push(vec![hook]);
149-
}
139+
partitions.push(vec![hook]);
150140
}
151-
partitions.extend(groups);
152141
}
153142

154143
partitions
155144
}
156145

146+
fn same_install_partition(left: &Hook, right: &Hook) -> bool {
147+
partition_language(left.language) == partition_language(right.language)
148+
&& left.repo().identity() == right.repo().identity()
149+
&& left.additional_dependencies == right.additional_dependencies
150+
}
151+
152+
fn partition_language(language: Language) -> Language {
153+
// Both `pygrep` and Python may provision Python, so schedule them as one language.
154+
if language == Language::Pygrep {
155+
Language::Python
156+
} else {
157+
language
158+
}
159+
}
160+
157161
/// Cached metadata for one environment found in the store hooks directory.
158162
///
159163
/// Health is checked lazily because scanning the store can find many environments that will not
@@ -172,10 +176,6 @@ impl CachedInstallInfo {
172176
}
173177
}
174178

175-
fn matches(&self, hook: &Hook) -> bool {
176-
self.info.matches(hook)
177-
}
178-
179179
fn info(&self) -> Arc<InstallInfo> {
180180
self.info.clone()
181181
}
@@ -238,15 +238,16 @@ impl InstallCache {
238238
/// Return a healthy installed environment from the store cache for this hook.
239239
///
240240
/// This only looks at environments loaded from `store.hooks_dir()`. Environments created
241-
/// during the current install call are reused inside `install_partition`, where hooks with
242-
/// the same install key are installed sequentially.
241+
/// during the current install call are reused inside `install_partition`, where hooks in the
242+
/// same install partition are processed sequentially.
243243
pub(crate) async fn installed_hook(
244244
&self,
245245
store: &Store,
246246
hook: Arc<Hook>,
247247
) -> Option<InstalledHook> {
248+
let requirement = hook.environment_requirement()?;
248249
for env in self.installed_hooks(store).await {
249-
if env.matches(&hook) && env.ensure_healthy().await {
250+
if requirement.is_satisfied_by(env.info_ref()) && env.ensure_healthy().await {
250251
return Some(InstalledHook::Installed {
251252
hook,
252253
info: env.info(),

0 commit comments

Comments
 (0)