@@ -57,20 +57,28 @@ use serde::Deserialize;
5757/// plugin's `PLUGIN_NAME` in Hatch's source.
5858const VIRTUAL_ENV_SUBDIR : [ & str ; 2 ] = [ "env" , "virtual" ] ;
5959
60- /// Per-workspace cache of resolved Hatch virtual directories and the set
61- /// of declared env names for that workspace. Each entry is
62- /// `(workspace_root, resolved_virtual_dirs, env_matcher)` and is
63- /// populated by `configure()`.
60+ /// Per-workspace cache entry: workspace root, resolved
61+ /// `dirs.env.virtual` paths, and the precomputed env-name allowlist.
6462///
65- /// `env_matcher ` is used as a Hatch-specific guard when matching
66- /// venvs in workspace-configured `dirs.env.virtual` directories: a shared
63+ /// `matcher ` is used as a Hatch-specific guard when matching venvs in
64+ /// workspace-configured `dirs.env.virtual` directories: a shared
6765/// directory like `~/.virtualenvs` can contain non-Hatch envs (created by
6866/// virtualenvwrapper, plain `venv`, etc.), so we only claim a venv if its
6967/// leaf directory name matches one of the env names declared in the
7068/// project's Hatch configuration. The matcher pre-normalizes names so the
7169/// `try_from()` hot path avoids per-call `to_lowercase()` / `format!()`
7270/// allocations over the allowlist.
73- type WorkspaceVirtualDirs = Vec < ( PathBuf , Vec < PathBuf > , EnvNameMatcher ) > ;
71+ struct WorkspaceEntry {
72+ workspace : PathBuf ,
73+ virtual_dirs : Vec < PathBuf > ,
74+ matcher : EnvNameMatcher ,
75+ }
76+
77+ /// Per-workspace cache populated by `configure()`. Entries are wrapped in
78+ /// `Arc` so `find()` can snapshot the cache (clone the Vec of Arcs) and
79+ /// release the lock cheaply before doing filesystem I/O — no deep
80+ /// `Vec<PathBuf>` / matcher clone per call.
81+ type WorkspaceVirtualDirs = Vec < Arc < WorkspaceEntry > > ;
7482
7583pub struct Hatch {
7684 /// Default storage directory for Hatch virtual environments — i.e.
@@ -132,11 +140,11 @@ impl Locator for Hatch {
132140 // — both `virtual_dirs` and `env_names` come from the same
133141 // TOML sections, so we read each file once here.
134142 let ( virtual_dirs, env_names) = resolve_workspace_hatch_config ( workspace) ;
135- new_cache. push ( (
136- workspace. clone ( ) ,
143+ new_cache. push ( Arc :: new ( WorkspaceEntry {
144+ workspace : workspace . clone ( ) ,
137145 virtual_dirs,
138- EnvNameMatcher :: from_names ( env_names) ,
139- ) ) ;
146+ matcher : EnvNameMatcher :: from_names ( env_names) ,
147+ } ) ) ;
140148 }
141149 }
142150 * self
@@ -184,17 +192,17 @@ impl Locator for Hatch {
184192 . workspace_virtual_dirs
185193 . lock ( )
186194 . expect ( "workspace_virtual_dirs mutex poisoned" ) ;
187- ' workspaces: for ( workspace , virtual_dirs , matcher ) in cache. iter ( ) {
188- for virtual_dir in virtual_dirs {
195+ ' workspaces: for entry in cache. iter ( ) {
196+ for virtual_dir in & entry . virtual_dirs {
189197 if prefix_is_directly_under ( & prefix, virtual_dir) {
190198 let env_name = prefix
191199 . file_name ( )
192200 . map ( |n| n. to_string_lossy ( ) . to_string ( ) )
193201 . unwrap_or_default ( ) ;
194- if !matcher. matches ( & env_name) {
202+ if !entry . matcher . matches ( & env_name) {
195203 continue ;
196204 }
197- classification = Some ( ( env_name, Some ( workspace. clone ( ) ) ) ) ;
205+ classification = Some ( ( env_name, Some ( entry . workspace . clone ( ) ) ) ) ;
198206 break ' workspaces;
199207 }
200208 }
@@ -238,16 +246,22 @@ impl Locator for Hatch {
238246 }
239247
240248 // 2. Walk project-local virtual directories for each configured workspace.
241- // Apply the same env-name guard as `try_from()` so shared directories
242- // (e.g. `~/.virtualenvs`) only yield the workspace's declared envs.
243- let workspaces = self
249+ // Snapshot the cache (cheap `Arc` clones) under the lock, then
250+ // release the lock before doing filesystem I/O. Apply the same
251+ // env-name guard as `try_from()` so shared directories (e.g.
252+ // `~/.virtualenvs`) only yield the workspace's declared envs.
253+ let workspaces: Vec < Arc < WorkspaceEntry > > = self
244254 . workspace_virtual_dirs
245255 . lock ( )
246256 . expect ( "workspace_virtual_dirs mutex poisoned" )
247257 . clone ( ) ;
248- for ( workspace, virtual_dirs, matcher) in & workspaces {
249- for virtual_dir in virtual_dirs {
250- for env in find_envs_in_flat_dir ( virtual_dir, Some ( workspace. clone ( ) ) , matcher) {
258+ for entry in & workspaces {
259+ for virtual_dir in & entry. virtual_dirs {
260+ for env in find_envs_in_flat_dir (
261+ virtual_dir,
262+ Some ( entry. workspace . clone ( ) ) ,
263+ & entry. matcher ,
264+ ) {
251265 reporter. report_environment ( & env) ;
252266 }
253267 }
@@ -616,21 +630,25 @@ fn find_envs_in_default_storage(storage: &Path) -> Vec<PythonEnvironment> {
616630/// (`try_from()` / `find_envs_in_flat_dir()`) avoids per-call `format!()`
617631/// allocations.
618632///
619- /// On case-insensitive filesystems (Windows / default macOS) the on-disk
620- /// leaf may differ in case from the TOML key, so we lowercase both sides
621- /// on those platforms at construction time.
633+ /// On case-insensitive filesystems (default on Windows) the on-disk leaf
634+ /// may differ in case from the TOML key, so we lowercase both sides on
635+ /// Windows at construction time. macOS volumes can be either case-sensitive
636+ /// (default APFS) or case-insensitive (HFS+ / case-insensitive APFS), and
637+ /// `norm_case()` itself does not case-fold on macOS — so we keep the
638+ /// allowlist comparison byte-exact there to stay consistent with how paths
639+ /// are normalized elsewhere in this crate.
622640#[ derive( Clone , Default , Debug ) ]
623641struct EnvNameMatcher {
624642 /// (normalized_name, normalized_name + ".") pairs.
625643 entries : Vec < ( String , String ) > ,
626644}
627645
628646fn normalize_env_name ( s : & str ) -> String {
629- #[ cfg( any ( windows, target_os = "macos" ) ) ]
647+ #[ cfg( windows) ]
630648 {
631649 s. to_lowercase ( )
632650 }
633- #[ cfg( not( any ( windows, target_os = "macos" ) ) ) ]
651+ #[ cfg( not( windows) ) ]
634652 {
635653 s. to_string ( )
636654 }
@@ -802,11 +820,11 @@ mod tests {
802820 let env_names = resolve_project_env_names ( workspace) ;
803821 Hatch {
804822 default_virtual_dir,
805- workspace_virtual_dirs : Arc :: new ( Mutex :: new ( vec ! [ (
806- workspace. to_path_buf( ) ,
823+ workspace_virtual_dirs : Arc :: new ( Mutex :: new ( vec ! [ Arc :: new ( WorkspaceEntry {
824+ workspace: workspace . to_path_buf( ) ,
807825 virtual_dirs,
808- EnvNameMatcher :: from_names( env_names) ,
809- ) ] ) ) ,
826+ matcher : EnvNameMatcher :: from_names( env_names) ,
827+ } ) ] ) ) ,
810828 }
811829 }
812830
@@ -1126,8 +1144,8 @@ mod tests {
11261144
11271145 let cached = locator. workspace_virtual_dirs . lock ( ) . unwrap ( ) . clone ( ) ;
11281146 assert_eq ! ( cached. len( ) , 1 ) ;
1129- assert_eq ! ( cached[ 0 ] . 0 , project) ;
1130- assert_eq ! ( cached[ 0 ] . 1 , vec![ norm_case( & virtual_dir) ] ) ;
1147+ assert_eq ! ( cached[ 0 ] . workspace , project) ;
1148+ assert_eq ! ( cached[ 0 ] . virtual_dirs , vec![ norm_case( & virtual_dir) ] ) ;
11311149 }
11321150
11331151 #[ cfg( target_os = "linux" ) ]
@@ -1424,9 +1442,9 @@ mod tests {
14241442 assert_eq ! ( envs. len( ) , 2 ) ;
14251443 }
14261444
1427- #[ cfg( any ( windows, target_os = "macos" ) ) ]
1445+ #[ cfg( windows) ]
14281446 #[ test]
1429- fn env_name_matches_is_case_insensitive_on_case_folding_filesystems ( ) {
1447+ fn env_name_matches_is_case_insensitive_on_windows ( ) {
14301448 let mut names = HashSet :: new ( ) ;
14311449 names. insert ( "Default" . to_string ( ) ) ;
14321450 let matcher = EnvNameMatcher :: from_names ( names) ;
0 commit comments