@@ -273,6 +273,13 @@ fn get_default_virtual_dir(environment: &dyn Environment) -> Option<PathBuf> {
273273 // `HATCH_DATA_DIR=~/.local/share/hatch` resolves to the user
274274 // home rather than a literal `~` directory.
275275 let expanded = expand_path ( PathBuf :: from ( trimmed) ) ;
276+ // If the home directory is unavailable, `expand_path()` returns
277+ // the input verbatim. Don't normalize a leading `~` into a
278+ // literal directory under cwd — bail out so Hatch envs are not
279+ // attributed to a bogus path.
280+ if path_starts_with_tilde ( & expanded) {
281+ return None ;
282+ }
276283 return Some ( norm_case ( append_virtual_subdir ( expanded) ) ) ;
277284 }
278285 }
@@ -289,6 +296,14 @@ fn append_virtual_subdir(data_dir: PathBuf) -> PathBuf {
289296 path
290297}
291298
299+ /// Returns true if `path` still begins with a literal `~`, indicating that
300+ /// `expand_path()` could not resolve the user's home directory (no HOME /
301+ /// USERPROFILE set). Such paths must not be normalized or joined against
302+ /// the workspace root, since `~` was not the user's intended directory.
303+ fn path_starts_with_tilde ( path : & Path ) -> bool {
304+ path. to_str ( ) . is_some_and ( |s| s. starts_with ( '~' ) )
305+ }
306+
292307/// Platform default for Hatch's data directory.
293308///
294309/// Mirrors `platformdirs.user_data_dir("hatch", appauthor=False)`.
@@ -470,6 +485,12 @@ fn resolve_virtual_paths_against_workspace(workspace: &Path, raw: Vec<String>) -
470485 // "~/.virtualenvs" resolve to the user home rather than being
471486 // joined onto the workspace as a relative path.
472487 let expanded = expand_path ( PathBuf :: from ( trimmed) ) ;
488+ // If the home directory is unavailable, `expand_path()` returns
489+ // the input verbatim. Skip such entries rather than joining a
490+ // literal `~` onto the workspace root (e.g. `<workspace>/~/...`).
491+ if path_starts_with_tilde ( & expanded) {
492+ continue ;
493+ }
473494 let resolved = if expanded. is_absolute ( ) {
474495 expanded
475496 } else {
@@ -1031,6 +1052,46 @@ mod tests {
10311052 assert_eq ! ( dirs, vec![ norm_case( & virtualenvs) ] ) ;
10321053 }
10331054
1055+ #[ test]
1056+ fn resolve_project_virtual_dirs_skips_unexpanded_tilde ( ) {
1057+ // If HOME / USERPROFILE are unset, `expand_path("~/.virtualenvs")`
1058+ // returns the input verbatim. We must not join `~` onto the
1059+ // workspace root (yielding `<workspace>/~/.virtualenvs`) or pass
1060+ // a tilde-prefixed path through `norm_case()` — both would
1061+ // misclassify unrelated envs.
1062+ let _guard = ENV_LOCK . lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
1063+
1064+ let temp = TempDir :: new ( ) . unwrap ( ) ;
1065+ let project = temp. path ( ) . join ( "proj" ) ;
1066+ fs:: create_dir_all ( & project) . unwrap ( ) ;
1067+ fs:: write (
1068+ project. join ( "pyproject.toml" ) ,
1069+ b"[tool.hatch.dirs.env]\n virtual = \" ~/.virtualenvs\" \n " ,
1070+ )
1071+ . unwrap ( ) ;
1072+
1073+ let prev_home = std:: env:: var_os ( "HOME" ) ;
1074+ let prev_user_profile = std:: env:: var_os ( "USERPROFILE" ) ;
1075+ std:: env:: remove_var ( "HOME" ) ;
1076+ std:: env:: remove_var ( "USERPROFILE" ) ;
1077+
1078+ let dirs = resolve_project_virtual_dirs ( & project) ;
1079+
1080+ match prev_home {
1081+ Some ( v) => std:: env:: set_var ( "HOME" , v) ,
1082+ None => std:: env:: remove_var ( "HOME" ) ,
1083+ }
1084+ match prev_user_profile {
1085+ Some ( v) => std:: env:: set_var ( "USERPROFILE" , v) ,
1086+ None => std:: env:: remove_var ( "USERPROFILE" ) ,
1087+ }
1088+
1089+ assert ! (
1090+ dirs. is_empty( ) ,
1091+ "unexpanded tilde paths must not be claimed: got {dirs:?}"
1092+ ) ;
1093+ }
1094+
10341095 #[ test]
10351096 fn configure_caches_workspace_virtual_dirs ( ) {
10361097 // try_from() must not re-read pyproject.toml on every call; configure()
0 commit comments