@@ -245,8 +245,13 @@ fn get_default_virtual_dir(environment: &dyn Environment) -> Option<PathBuf> {
245245 // platform-default envs to Hatch when the user has redirected Hatch
246246 // elsewhere.
247247 if let Some ( custom) = environment. get_env_var ( "HATCH_DATA_DIR" . to_string ( ) ) {
248- if !custom. is_empty ( ) {
249- return Some ( norm_case ( append_virtual_subdir ( PathBuf :: from ( custom) ) ) ) ;
248+ let trimmed = custom. trim ( ) ;
249+ if !trimmed. is_empty ( ) {
250+ // Expand ~ / ${HOME} / ${USERNAME} so a value like
251+ // `HATCH_DATA_DIR=~/.local/share/hatch` resolves to the user
252+ // home rather than a literal `~` directory.
253+ let expanded = expand_path ( PathBuf :: from ( trimmed) ) ;
254+ return Some ( norm_case ( append_virtual_subdir ( expanded) ) ) ;
250255 }
251256 }
252257 Some ( norm_case ( append_virtual_subdir ( platform_default_data_dir (
@@ -388,10 +393,17 @@ struct HatchDirs {
388393fn resolve_project_virtual_dirs ( workspace : & Path ) -> Vec < PathBuf > {
389394 let mut dirs = Vec :: new ( ) ;
390395 for raw in read_configured_virtual_paths ( workspace) {
396+ // Skip empty/whitespace values. Without this, `virtual = ""` would
397+ // resolve to the workspace root and we'd misclassify any venv
398+ // directly under the workspace (e.g. `./.venv`) as Hatch-managed.
399+ let trimmed = raw. trim ( ) ;
400+ if trimmed. is_empty ( ) {
401+ continue ;
402+ }
391403 // Expand ~ and ${HOME}/${USERNAME} so configured values like
392404 // "~/.virtualenvs" resolve to the user home rather than being
393405 // joined onto the workspace as a relative path.
394- let expanded = expand_path ( PathBuf :: from ( & raw ) ) ;
406+ let expanded = expand_path ( PathBuf :: from ( trimmed ) ) ;
395407 let resolved = if expanded. is_absolute ( ) {
396408 expanded
397409 } else {
@@ -999,4 +1011,99 @@ mod tests {
9991011 let expected = norm_case ( custom. join ( "env" ) . join ( "virtual" ) ) ;
10001012 assert_eq ! ( get_default_virtual_dir( & env) , Some ( expected) ) ;
10011013 }
1014+
1015+ #[ test]
1016+ fn default_virtual_dir_expands_tilde_in_hatch_data_dir ( ) {
1017+ // A value like `HATCH_DATA_DIR=~/.local/share/hatch` must be
1018+ // expanded against the user's home rather than be treated as a
1019+ // literal `~` directory.
1020+ let _guard = ENV_LOCK . lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
1021+
1022+ let temp = TempDir :: new ( ) . unwrap ( ) ;
1023+ let fake_home = temp. path ( ) . join ( "home" ) ;
1024+ fs:: create_dir_all ( & fake_home) . unwrap ( ) ;
1025+
1026+ let prev_home = std:: env:: var_os ( "HOME" ) ;
1027+ let prev_user_profile = std:: env:: var_os ( "USERPROFILE" ) ;
1028+ std:: env:: set_var ( "HOME" , & fake_home) ;
1029+ std:: env:: set_var ( "USERPROFILE" , & fake_home) ;
1030+
1031+ let mut vars = HashMap :: new ( ) ;
1032+ vars. insert (
1033+ "HATCH_DATA_DIR" . to_string ( ) ,
1034+ "~/.local/share/hatch" . to_string ( ) ,
1035+ ) ;
1036+ let env = TestEnv {
1037+ home : Some ( fake_home. clone ( ) ) ,
1038+ vars,
1039+ } ;
1040+ let resolved = get_default_virtual_dir ( & env) ;
1041+
1042+ match prev_home {
1043+ Some ( v) => std:: env:: set_var ( "HOME" , v) ,
1044+ None => std:: env:: remove_var ( "HOME" ) ,
1045+ }
1046+ match prev_user_profile {
1047+ Some ( v) => std:: env:: set_var ( "USERPROFILE" , v) ,
1048+ None => std:: env:: remove_var ( "USERPROFILE" ) ,
1049+ }
1050+
1051+ let expected = norm_case (
1052+ fake_home
1053+ . join ( ".local" )
1054+ . join ( "share" )
1055+ . join ( "hatch" )
1056+ . join ( "env" )
1057+ . join ( "virtual" ) ,
1058+ ) ;
1059+ assert_eq ! ( resolved, Some ( expected) ) ;
1060+ }
1061+
1062+ #[ test]
1063+ fn default_virtual_dir_treats_whitespace_hatch_data_dir_as_unset ( ) {
1064+ // Whitespace-only HATCH_DATA_DIR must be treated as unset so we
1065+ // fall back to the platform default rather than resolving to
1066+ // a literal whitespace directory.
1067+ let temp = TempDir :: new ( ) . unwrap ( ) ;
1068+ let mut vars = HashMap :: new ( ) ;
1069+ vars. insert ( "HATCH_DATA_DIR" . to_string ( ) , " " . to_string ( ) ) ;
1070+ let env = TestEnv {
1071+ home : Some ( temp. path ( ) . to_path_buf ( ) ) ,
1072+ vars,
1073+ } ;
1074+ // Should NOT be the literal " /env/virtual"; should resolve via
1075+ // the platform default (or None if home is unavailable).
1076+ let resolved = get_default_virtual_dir ( & env) ;
1077+ if let Some ( p) = resolved {
1078+ assert ! ( !p. to_string_lossy( ) . contains( " " ) ) ;
1079+ }
1080+ }
1081+
1082+ #[ test]
1083+ fn resolve_project_virtual_dirs_skips_empty_value ( ) {
1084+ // `virtual = ""` must not resolve to the workspace root and
1085+ // misclassify unrelated venvs under the workspace as Hatch.
1086+ let temp = TempDir :: new ( ) . unwrap ( ) ;
1087+ let project = temp. path ( ) . join ( "proj" ) ;
1088+ fs:: create_dir_all ( & project) . unwrap ( ) ;
1089+ fs:: write (
1090+ project. join ( "pyproject.toml" ) ,
1091+ b"[tool.hatch.dirs.env]\n virtual = \" \" \n " ,
1092+ )
1093+ . unwrap ( ) ;
1094+ assert ! ( resolve_project_virtual_dirs( & project) . is_empty( ) ) ;
1095+ }
1096+
1097+ #[ test]
1098+ fn resolve_project_virtual_dirs_skips_whitespace_value ( ) {
1099+ let temp = TempDir :: new ( ) . unwrap ( ) ;
1100+ let project = temp. path ( ) . join ( "proj" ) ;
1101+ fs:: create_dir_all ( & project) . unwrap ( ) ;
1102+ fs:: write (
1103+ project. join ( "pyproject.toml" ) ,
1104+ b"[tool.hatch.dirs.env]\n virtual = \" \" \n " ,
1105+ )
1106+ . unwrap ( ) ;
1107+ assert ! ( resolve_project_virtual_dirs( & project) . is_empty( ) ) ;
1108+ }
10021109}
0 commit comments