diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 4bb7309..fa9ea70 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -406,6 +406,94 @@ pub fn run_session(command: SessionCommand, options: &Options) -> Result std::path::PathBuf { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + let wide: Vec = path.as_os_str().encode_wide().collect(); + if let Some(rest) = wide.strip_prefix(UNC_PREFIX) { + // `\\?\UNC\server\share` ⟺ `\\server\share`. + let mut full = vec![0x5C, 0x5C]; + full.extend_from_slice(rest); + std::ffi::OsString::from_wide(&full).into() + } else if let Some(rest) = wide.strip_prefix(VERBATIM_PREFIX) { + std::ffi::OsString::from_wide(rest).into() + } else { + path.to_path_buf() + } +} + +#[cfg(not(windows))] +fn strip_verbatim_prefix(path: &std::path::Path) -> std::path::PathBuf { + path.to_path_buf() +} + +/// Component-wise `starts_with` that folds case on Windows, where `C:\Users` +/// and `c:\users` are the same directory. Non-Windows keeps the exact +/// `Path::starts_with`. +fn path_starts_with(child: &std::path::Path, parent: &std::path::Path) -> bool { + #[cfg(windows)] + { + let mut child_comps = child.components(); + let mut parent_comps = parent.components(); + loop { + match (parent_comps.next(), child_comps.next()) { + (None, _) => return true, + (Some(_), None) => return false, + (Some(p), Some(c)) => { + // Unicode lowercase approximates the filesystem's own + // case folding; ASCII-only would miss e.g. `é` vs `É`. + if p.as_os_str().to_string_lossy().to_lowercase() + != c.as_os_str().to_string_lossy().to_lowercase() + { + return false; + } + } + } + } + } + #[cfg(not(windows))] + { + child.starts_with(parent) + } +} + +/// Canonicalize the longest existing prefix of `path`, re-appending the +/// vanished tail verbatim, so the parent still resolves symlinks, junctions, +/// or 8.3 short names the raw spelling would mismatch. +fn canonicalize_lenient(path: &std::path::Path) -> std::path::PathBuf { + use std::path::Component; + let mut tail: Vec = Vec::new(); + let mut current = path; + loop { + if let Ok(base) = current.canonicalize() { + let mut out = strip_verbatim_prefix(&base); + for component in tail.iter().rev() { + out.push(component); + } + return out; + } + let mut components = current.components(); + match components.next_back() { + // Empty path, or only a prefix/root remains: nothing resolvable. + None | Some(Component::Prefix(_) | Component::RootDir) => { + return strip_verbatim_prefix(path); + } + Some(last) => { + tail.push(last.as_os_str().to_os_string()); + current = components.as_path(); + } + } + } +} + /// True when a session's recorded `cwd` is `dir` or anywhere under it, so a /// monorepo session started in `repo/packages/foo` shows up when listing /// `repo`. The check is component-wise (`/foo/barbaz` is not under @@ -415,8 +503,10 @@ pub fn run_session(command: SessionCommand, options: &Options) -> Result bool { - let canon = |p: &std::path::Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()); - canon(std::path::Path::new(session_cwd)).starts_with(canon(dir)) + path_starts_with( + &canonicalize_lenient(std::path::Path::new(session_cwd)), + &canonicalize_lenient(dir), + ) } /// The `--from`/`--cwd` session filters shared by `list` and `query`. @@ -693,6 +783,46 @@ mod filter_tests { Some(repo) )); } + + #[test] + fn strip_verbatim_prefix_leaves_plain_paths_alone() { + let p = std::path::Path::new("some/relative/dir"); + assert_eq!(super::strip_verbatim_prefix(p), p.to_path_buf()); + } + + #[cfg(windows)] + #[test] + fn strip_verbatim_prefix_strips_device_and_unc_forms() { + assert_eq!( + super::strip_verbatim_prefix(std::path::Path::new(r"\\?\C:\some\repo")), + std::path::PathBuf::from(r"C:\some\repo") + ); + assert_eq!( + super::strip_verbatim_prefix(std::path::Path::new(r"\\?\UNC\server\share")), + std::path::PathBuf::from(r"\\server\share") + ); + } + + // Drive-letter paths only parse where `\` separates components. + // The tempdir case is the reported scenario: live `dir`, vanished child. + #[cfg(windows)] + #[test] + fn cwd_filter_matches_vanished_child_of_live_dir() { + let dir = tempfile::tempdir().unwrap(); + let gone = dir.path().join("packages").join("foo"); + assert!(super::under_dir(gone.to_str().unwrap(), dir.path())); + } + + #[cfg(windows)] + #[test] + fn cwd_filter_handles_windows_verbatim_prefixes_and_casing() { + let dir = std::path::Path::new(r"C:\some\repo"); + assert!(super::under_dir(r"C:\some\repo\packages\foo", dir)); + assert!(super::under_dir(r"c:\Some\Repo\packages\foo", dir)); + assert!(super::under_dir(r"C:/some/repo/packages/foo", dir)); + assert!(!super::under_dir(r"C:\some\repo2", dir)); + assert!(!super::under_dir(r"C:\other\repo", dir)); + } } #[cfg(test)]