Fix git features not working when a Windows host collaborates with a unix guest (#43515)

Cole Miller created

We were using `std::path::Path::strip_prefix` to determine which
repository an absolute path belongs to, which doesn't work when the
paths are Windows-style but the code is running on unix. Replace it with
a platform-agnostic implementation of `strip_prefix`.

Release Notes:

- Fixed git features not working when a Windows host collaborates with a
unix guest

Change summary

crates/agent_ui/src/acp/message_editor.rs           |   2 
crates/agent_ui/src/acp/thread_view.rs              |   2 
crates/file_finder/src/file_finder.rs               |   4 
crates/file_finder/src/file_finder_tests.rs         |   2 
crates/file_finder/src/open_path_prompt.rs          |  12 +
crates/fuzzy/src/paths.rs                           |   2 
crates/git_ui/src/git_panel.rs                      |   7 
crates/project/src/git_store.rs                     |   6 
crates/project/src/project.rs                       |   2 
crates/project_panel/src/project_panel.rs           |   2 
crates/settings_ui/src/settings_ui.rs               |   2 
crates/toolchain_selector/src/toolchain_selector.rs |   2 
crates/util/src/paths.rs                            | 134 ++++++++++++++
crates/vim/src/command.rs                           |   4 
crates/worktree/src/worktree.rs                     |   6 
15 files changed, 159 insertions(+), 30 deletions(-)

Detailed changes

crates/agent_ui/src/acp/message_editor.rs 🔗

@@ -1423,7 +1423,7 @@ mod tests {
             rel_path("b/eight.txt"),
         ];
 
-        let slash = PathStyle::local().separator();
+        let slash = PathStyle::local().primary_separator();
 
         let mut opened_editors = Vec::new();
         for path in paths {

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -3989,7 +3989,7 @@ impl AcpThreadView {
                 let file = buffer.read(cx).file()?;
                 let path = file.path();
                 let path_style = file.path_style(cx);
-                let separator = file.path_style(cx).separator();
+                let separator = file.path_style(cx).primary_separator();
 
                 let file_path = path.parent().and_then(|parent| {
                     if parent.is_empty() {

crates/file_finder/src/file_finder.rs 🔗

@@ -1060,7 +1060,7 @@ impl FileFinderDelegate {
                         (
                             filename.to_string(),
                             Vec::new(),
-                            prefix.display(path_style).to_string() + path_style.separator(),
+                            prefix.display(path_style).to_string() + path_style.primary_separator(),
                             Vec::new(),
                         )
                     } else {
@@ -1071,7 +1071,7 @@ impl FileFinderDelegate {
                                 .map_or(String::new(), |f| f.to_string_lossy().into_owned()),
                             Vec::new(),
                             entry_path.absolute.parent().map_or(String::new(), |path| {
-                                path.to_string_lossy().into_owned() + path_style.separator()
+                                path.to_string_lossy().into_owned() + path_style.primary_separator()
                             }),
                             Vec::new(),
                         )

crates/file_finder/src/file_finder_tests.rs 🔗

@@ -1598,7 +1598,7 @@ async fn test_history_match_positions(cx: &mut gpui::TestAppContext) {
         assert_eq!(file_label.highlight_indices(), &[0, 1, 2]);
         assert_eq!(
             path_label.text(),
-            format!("test{}", PathStyle::local().separator())
+            format!("test{}", PathStyle::local().primary_separator())
         );
         assert_eq!(path_label.highlight_indices(), &[] as &[usize]);
     });

crates/file_finder/src/open_path_prompt.rs 🔗

@@ -559,7 +559,7 @@ impl PickerDelegate for OpenPathDelegate {
                         parent_path,
                         candidate.path.string,
                         if candidate.is_dir {
-                            path_style.separator()
+                            path_style.primary_separator()
                         } else {
                             ""
                         }
@@ -569,7 +569,7 @@ impl PickerDelegate for OpenPathDelegate {
                         parent_path,
                         candidate.path.string,
                         if candidate.is_dir {
-                            path_style.separator()
+                            path_style.primary_separator()
                         } else {
                             ""
                         }
@@ -826,7 +826,13 @@ impl PickerDelegate for OpenPathDelegate {
     }
 
     fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
-        Arc::from(format!("[directory{}]filename.ext", self.path_style.separator()).as_str())
+        Arc::from(
+            format!(
+                "[directory{}]filename.ext",
+                self.path_style.primary_separator()
+            )
+            .as_str(),
+        )
     }
 
     fn separators_after_indices(&self) -> Vec<usize> {

crates/fuzzy/src/paths.rs 🔗

@@ -107,7 +107,7 @@ pub fn match_fixed_path_set(
                 .display(path_style)
                 .chars()
                 .collect::<Vec<_>>();
-            path_prefix_chars.extend(path_style.separator().chars());
+            path_prefix_chars.extend(path_style.primary_separator().chars());
             let lowercase_pfx = path_prefix_chars
                 .iter()
                 .map(|c| c.to_ascii_lowercase())

crates/git_ui/src/git_panel.rs 🔗

@@ -4351,8 +4351,11 @@ impl GitPanel {
                             .when(strikethrough, Label::strikethrough),
                     ),
                     (true, false) => this.child(
-                        self.entry_label(format!("{dir}{}", path_style.separator()), path_color)
-                            .when(strikethrough, Label::strikethrough),
+                        self.entry_label(
+                            format!("{dir}{}", path_style.primary_separator()),
+                            path_color,
+                        )
+                        .when(strikethrough, Label::strikethrough),
                     ),
                     _ => this,
                 }

crates/project/src/git_store.rs 🔗

@@ -3222,10 +3222,8 @@ impl RepositorySnapshot {
         abs_path: &Path,
         path_style: PathStyle,
     ) -> Option<RepoPath> {
-        abs_path
-            .strip_prefix(&work_directory_abs_path)
-            .ok()
-            .and_then(|path| RepoPath::from_std_path(path, path_style).ok())
+        let rel_path = path_style.strip_prefix(abs_path, work_directory_abs_path)?;
+        Some(RepoPath::from_rel_path(&rel_path))
     }
 
     pub fn had_conflict_on_last_merge_head_change(&self, repo_path: &RepoPath) -> bool {

crates/project/src/project.rs 🔗

@@ -927,7 +927,7 @@ impl DirectoryLister {
             .map(|worktree| worktree.read(cx).abs_path().to_string_lossy().into_owned())
             .or_else(|| std::env::home_dir().map(|dir| dir.to_string_lossy().into_owned()))
             .map(|mut s| {
-                s.push_str(path_style.separator());
+                s.push_str(path_style.primary_separator());
                 s
             })
             .unwrap_or_else(|| {

crates/project_panel/src/project_panel.rs 🔗

@@ -4837,7 +4837,7 @@ impl ProjectPanel {
                                         .collect::<Vec<_>>();
                                     let active_index = folded_ancestors.active_index();
                                     let components_len = components.len();
-                                    let delimiter = SharedString::new(path_style.separator());
+                                    let delimiter = SharedString::new(path_style.primary_separator());
                                     for (index, component) in components.iter().enumerate() {
                                         if index != 0 {
                                                 let delimiter_target_index = index - 1;

crates/settings_ui/src/settings_ui.rs 🔗

@@ -2192,7 +2192,7 @@ impl SettingsWindow {
                         format!(
                             "{}{}{}",
                             directory_name,
-                            path_style.separator(),
+                            path_style.primary_separator(),
                             path.display(path_style)
                         )
                     }

crates/toolchain_selector/src/toolchain_selector.rs 🔗

@@ -876,7 +876,7 @@ impl ToolchainSelectorDelegate {
             .strip_prefix(&worktree_root)
             .ok()
             .and_then(|suffix| suffix.to_str())
-            .map(|suffix| format!(".{}{suffix}", path_style.separator()).into())
+            .map(|suffix| format!(".{}{suffix}", path_style.primary_separator()).into())
             .unwrap_or(path)
     }
 }

crates/util/src/paths.rs 🔗

@@ -3,6 +3,7 @@ use globset::{Glob, GlobSet, GlobSetBuilder};
 use itertools::Itertools;
 use regex::Regex;
 use serde::{Deserialize, Serialize};
+use std::borrow::Cow;
 use std::cmp::Ordering;
 use std::error::Error;
 use std::fmt::{Display, Formatter};
@@ -331,13 +332,20 @@ impl PathStyle {
     }
 
     #[inline]
-    pub fn separator(&self) -> &'static str {
+    pub fn primary_separator(&self) -> &'static str {
         match self {
             PathStyle::Posix => "/",
             PathStyle::Windows => "\\",
         }
     }
 
+    pub fn separators(&self) -> &'static [&'static str] {
+        match self {
+            PathStyle::Posix => &["/"],
+            PathStyle::Windows => &["\\", "/"],
+        }
+    }
+
     pub fn is_windows(&self) -> bool {
         *self == PathStyle::Windows
     }
@@ -353,25 +361,54 @@ impl PathStyle {
         } else {
             Some(format!(
                 "{left}{}{right}",
-                if left.ends_with(self.separator()) {
+                if left.ends_with(self.primary_separator()) {
                     ""
                 } else {
-                    self.separator()
+                    self.primary_separator()
                 }
             ))
         }
     }
 
     pub fn split(self, path_like: &str) -> (Option<&str>, &str) {
-        let Some(pos) = path_like.rfind(self.separator()) else {
+        let Some(pos) = path_like.rfind(self.primary_separator()) else {
             return (None, path_like);
         };
-        let filename_start = pos + self.separator().len();
+        let filename_start = pos + self.primary_separator().len();
         (
             Some(&path_like[..filename_start]),
             &path_like[filename_start..],
         )
     }
+
+    pub fn strip_prefix<'a>(
+        &self,
+        child: &'a Path,
+        parent: &'a Path,
+    ) -> Option<std::borrow::Cow<'a, RelPath>> {
+        let parent = parent.to_str()?;
+        if parent.is_empty() {
+            return RelPath::new(child, *self).ok();
+        }
+        let parent = self
+            .separators()
+            .iter()
+            .find_map(|sep| parent.strip_suffix(sep))
+            .unwrap_or(parent);
+        let child = child.to_str()?;
+        let stripped = child.strip_prefix(parent)?;
+        if let Some(relative) = self
+            .separators()
+            .iter()
+            .find_map(|sep| stripped.strip_prefix(sep))
+        {
+            RelPath::new(relative.as_ref(), *self).ok()
+        } else if stripped.is_empty() {
+            Some(Cow::Borrowed(RelPath::empty()))
+        } else {
+            None
+        }
+    }
 }
 
 #[derive(Debug, Clone)]
@@ -788,7 +825,7 @@ impl PathMatcher {
 
     fn check_with_end_separator(&self, path: &Path) -> bool {
         let path_str = path.to_string_lossy();
-        let separator = self.path_style.separator();
+        let separator = self.path_style.primary_separator();
         if path_str.ends_with(separator) {
             false
         } else {
@@ -1311,6 +1348,8 @@ impl WslPath {
 
 #[cfg(test)]
 mod tests {
+    use crate::rel_path::rel_path;
+
     use super::*;
     use util_macros::perf;
 
@@ -2480,6 +2519,89 @@ mod tests {
         assert_eq!(strip_path_suffix(base, suffix), None);
     }
 
+    #[test]
+    fn test_strip_prefix() {
+        let expected = [
+            (
+                PathStyle::Posix,
+                "/a/b/c",
+                "/a/b",
+                Some(rel_path("c").into_arc()),
+            ),
+            (
+                PathStyle::Posix,
+                "/a/b/c",
+                "/a/b/",
+                Some(rel_path("c").into_arc()),
+            ),
+            (
+                PathStyle::Posix,
+                "/a/b/c",
+                "/",
+                Some(rel_path("a/b/c").into_arc()),
+            ),
+            (PathStyle::Posix, "/a/b/c", "", None),
+            (PathStyle::Posix, "/a/b//c", "/a/b/", None),
+            (PathStyle::Posix, "/a/bc", "/a/b", None),
+            (
+                PathStyle::Posix,
+                "/a/b/c",
+                "/a/b/c",
+                Some(rel_path("").into_arc()),
+            ),
+            (
+                PathStyle::Windows,
+                "C:\\a\\b\\c",
+                "C:\\a\\b",
+                Some(rel_path("c").into_arc()),
+            ),
+            (
+                PathStyle::Windows,
+                "C:\\a\\b\\c",
+                "C:\\a\\b\\",
+                Some(rel_path("c").into_arc()),
+            ),
+            (
+                PathStyle::Windows,
+                "C:\\a\\b\\c",
+                "C:\\",
+                Some(rel_path("a/b/c").into_arc()),
+            ),
+            (PathStyle::Windows, "C:\\a\\b\\c", "", None),
+            (PathStyle::Windows, "C:\\a\\b\\\\c", "C:\\a\\b\\", None),
+            (PathStyle::Windows, "C:\\a\\bc", "C:\\a\\b", None),
+            (
+                PathStyle::Windows,
+                "C:\\a\\b/c",
+                "C:\\a\\b",
+                Some(rel_path("c").into_arc()),
+            ),
+            (
+                PathStyle::Windows,
+                "C:\\a\\b/c",
+                "C:\\a\\b\\",
+                Some(rel_path("c").into_arc()),
+            ),
+            (
+                PathStyle::Windows,
+                "C:\\a\\b/c",
+                "C:\\a\\b/",
+                Some(rel_path("c").into_arc()),
+            ),
+        ];
+        let actual = expected.clone().map(|(style, child, parent, _)| {
+            (
+                style,
+                child,
+                parent,
+                style
+                    .strip_prefix(child.as_ref(), parent.as_ref())
+                    .map(|rel_path| rel_path.into_arc()),
+            )
+        });
+        pretty_assertions::assert_eq!(actual, expected);
+    }
+
     #[cfg(target_os = "windows")]
     #[test]
     fn test_wsl_path() {

crates/vim/src/command.rs 🔗

@@ -965,7 +965,7 @@ impl VimCommand {
                 }
             };
 
-            let rel_path = if args.ends_with(PathStyle::local().separator()) {
+            let rel_path = if args.ends_with(PathStyle::local().primary_separator()) {
                 rel_path
             } else {
                 rel_path
@@ -998,7 +998,7 @@ impl VimCommand {
                         .display(PathStyle::local())
                         .to_string();
                     if dir.is_dir {
-                        path_string.push_str(PathStyle::local().separator());
+                        path_string.push_str(PathStyle::local().primary_separator());
                     }
                     path_string
                 })

crates/worktree/src/worktree.rs 🔗

@@ -999,7 +999,7 @@ impl Worktree {
             };
 
             if worktree_relative_path.components().next().is_some() {
-                full_path_string.push_str(self.path_style.separator());
+                full_path_string.push_str(self.path_style.primary_separator());
                 full_path_string.push_str(&worktree_relative_path.display(self.path_style));
             }
 
@@ -2108,8 +2108,8 @@ impl Snapshot {
         if path.file_name().is_some() {
             let mut abs_path = self.abs_path.to_string();
             for component in path.components() {
-                if !abs_path.ends_with(self.path_style.separator()) {
-                    abs_path.push_str(self.path_style.separator());
+                if !abs_path.ends_with(self.path_style.primary_separator()) {
+                    abs_path.push_str(self.path_style.primary_separator());
                 }
                 abs_path.push_str(component);
             }