mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
fix(git): key a repository by one spelling of its root
The same directory reaches this process under three names on Windows and only two of them compare equal. A pane and the OS say `C:\Users\x\repo`; Git for Windows is MSYS2, so `rev-parse --show-toplevel` and `--git-common-dir` say `C:/Users/x/repo` whatever shell asked; and `fs::canonicalize`, which is what `Host::canonicalize` was, says `\\?\C:\Users\x\repo`. `Path` forgives the first two of each other — it compares, hashes and prefix-matches by component, and both separators end one, which also covers a UNC share, a `\\wsl$` path and a `subst` drive. It does not forgive the third: `\\?\C:` parses as `Prefix::VerbatimDisk` where `C:` is `Prefix::Disk`, so it matches neither, by equality, by hash or by `starts_with`. Every cache in the SCM layer is keyed by exactly that comparison, so a root that came in past `canonicalize` and a root that came out of `git` name one repository and share nothing. `core::path_spelling` settles it once, at the boundary each path is created at rather than at each of the places one is later compared: `git_path` for everything `rev-parse` prints, and `LocalHost:: canonicalize` on its way out — `\\?\` is a Win32 API escape hatch, not part of a path's identity, and what the call is *for* (a junction, a `subst` drive, an 8.3 name, a symlink) is untouched. `GitStatusCache`, where a repository actually gets its identity, insists on it for its own keys too, so a caller that resolves a path cannot re-open the gap. The spelling landed on is the plain one, which is also what `ParseDisplayName` takes and the only one a person recognises in a tooltip; `ui::path_display::native_separators` now delegates its half of the rule rather than restating it in the other crate. What this is and is not. Traced through every production route by which a `\\?\` path could become a repo key: it cannot become a *pane cwd*, which is what keys `GitStatusCache` and reaches `overlay.cwd`. The kernel cwd probe is a `None` stub off macOS and Linux, so on Windows the cwd comes only from OSC 7 or the agent hook stream and neither resolves; the CLI refuses to canonicalize on purpose; restore, window state and the sidebar never do. So the diff overlay's re-read loop, and the panel and commit detail failing to recognise their own repository, are reachable today only where something resolves a path first — which in this tree is the tests. A separate CPU measurement on a real GUI agrees: zero `git.exe` spawns, idle or loaded, overlay open or closed. The one production route that does resolve and does reach a repo key is `editor_open_on_host`, which stores the canonicalized path as `OpenFile::path`; `scm_editor_target` then looks its parent up in `GitStatusCache` and misses, so on Windows a focused editor never subscribed to its repository's `.git` watch. That is a missing subscription rather than extra work: with the panel or the file tree also on screen their watch covers it, and alone it leaves the SCM data stale until something else asks. The rest of the value is the invariant and the tests. The caches were one resolved path away from splitting a repository in two at any of four surfaces, with no test anywhere holding them to a single spelling. Also worth naming, because the gate this was found behind blamed the wrong half: stripping `\\?\` *is* the fix, and slash direction is not what breaks a `PathBuf` — `path_equality_already_forgives_case_slashes_ and_a_trailing_separator` pins that, so a later change here cannot quietly start folding what `Path` already folds, or stop folding what it does not. The tests are ungated, deliberately. Windows is the only platform with three spellings and was the only one not running the comparison, which is how this lived; a `#[cfg(unix)]` on any of them would put it back. Where one needs a repository it runs `git init` in a temp directory rather than reading the checkout it is in. Found while un-gating the window and pane tests in #791, which left `scm::detail::detail_gpui_tests`, `scm::panel::a_settled_source_control_ panel_reaches_render_idle` and `diff_overlay::render_idle_gpui_tests` gated on this divergence.
This commit is contained in:
@@ -428,7 +428,7 @@ pub fn probe_diff(host: &dyn Host, root: &Path, req: &DiffRequest<'_>) -> Option
|
||||
return None;
|
||||
}
|
||||
let toplevel = git::git(host, root, &["rev-parse", "--show-toplevel"])?;
|
||||
let toplevel = PathBuf::from(toplevel.trim_end_matches(['\n', '\r']));
|
||||
let toplevel = git::git_path(toplevel.trim_end_matches(['\n', '\r']));
|
||||
let branch = git::branch_name(host, root)?;
|
||||
|
||||
let argv = req.args();
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<RepoSnapshot> {
|
||||
],
|
||||
)?;
|
||||
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
|
||||
let root = PathBuf::from(lines.next()?);
|
||||
let root = git_path(lines.next()?);
|
||||
let home = repo_home(&root, lines.next(), lines.next());
|
||||
let branch = branch_name(host, cwd)?;
|
||||
Some(RepoSnapshot {
|
||||
@@ -59,6 +59,19 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<RepoSnapshot> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A path `git` just printed, in the spelling the rest of tty7 keys by.
|
||||
///
|
||||
/// Git for Windows is MSYS2 and answers `rev-parse` with `C:/Users/…` whatever
|
||||
/// shell asked it. `Path` forgives that much on its own, but the same root also
|
||||
/// has to compare equal to one that came past `fs::canonicalize` — which spells
|
||||
/// it `\\?\C:\Users\…`, a different prefix component and so a different key.
|
||||
/// One spelling at the boundary, rather than a normalisation remembered at each
|
||||
/// of the places these roots are later compared. See
|
||||
/// [`crate::core::path_spelling`].
|
||||
pub(crate) fn git_path(printed: &str) -> PathBuf {
|
||||
crate::core::path_spelling::local_spelling_buf(printed)
|
||||
}
|
||||
|
||||
pub(crate) fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf {
|
||||
let (Some(git_dir), Some(common)) = (git_dir, common_dir) else {
|
||||
return root.to_path_buf();
|
||||
@@ -66,11 +79,13 @@ pub(crate) fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&
|
||||
if git_dir == common {
|
||||
return root.to_path_buf();
|
||||
}
|
||||
let common = Path::new(common);
|
||||
match (common.file_name(), common.parent()) {
|
||||
(Some(name), Some(parent)) if name == ".git" => parent.to_path_buf(),
|
||||
_ => common.to_path_buf(),
|
||||
let common = git_path(common);
|
||||
if common.file_name().is_some_and(|name| name == ".git")
|
||||
&& let Some(parent) = common.parent()
|
||||
{
|
||||
return parent.to_path_buf();
|
||||
}
|
||||
common
|
||||
}
|
||||
|
||||
pub fn branch_name(host: &dyn Host, cwd: &Path) -> Option<String> {
|
||||
@@ -679,4 +694,68 @@ mod tests {
|
||||
);
|
||||
assert_eq!(repo_home(root, None, None), root.to_path_buf());
|
||||
}
|
||||
|
||||
/// The root every git probe answers with is the directory the *OS* names,
|
||||
/// compared as a plain `PathBuf` — which is how every consumer compares
|
||||
/// it.
|
||||
///
|
||||
/// Not gated to any platform, deliberately. Git for Windows is MSYS2 and
|
||||
/// prints `C:/Users/…`; `Host::canonicalize` used to answer `\\?\C:\Users\
|
||||
/// …`; those are different `Prefix` components, so the two never matched
|
||||
/// and every SCM cache keyed by one missed the other. Nothing compared
|
||||
/// them on Windows, which is exactly why nobody noticed — a `#[cfg(unix)]`
|
||||
/// on this would put it straight back.
|
||||
#[test]
|
||||
fn a_probed_root_is_the_same_path_the_host_canonicalizes_to() {
|
||||
let host = h();
|
||||
let dir = std::env::temp_dir().join(format!("tty7-root-spelling-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let made = git(&*host, &dir, &["init", "--quiet"]).is_some();
|
||||
if !made {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return; // no git on this machine
|
||||
}
|
||||
assert!(super::test_support::pin_repo_config(&dir));
|
||||
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
|
||||
let mut commit = super::test_support::PINS.to_vec();
|
||||
commit.extend_from_slice(&["commit", "--quiet", "-m", "base"]);
|
||||
assert!(git(&*host, &dir, &["add", "-A"]).is_some());
|
||||
assert!(git(&*host, &dir, &commit).is_some());
|
||||
|
||||
// The one directory, under the two names this process can learn it by:
|
||||
// what the OS handed back, and what resolving it answers.
|
||||
let canonical = host.canonicalize(&dir).expect("the scratch dir resolves");
|
||||
|
||||
let snap = probe(&*host, &dir).expect("a repository was just created here");
|
||||
assert_eq!(
|
||||
snap.root, canonical,
|
||||
"the probed root and the resolved directory are one key"
|
||||
);
|
||||
assert_eq!(snap.home, canonical, "and so is a non-worktree's home");
|
||||
|
||||
// Asking from the resolved spelling has to reach the same answer, or
|
||||
// a pane whose cwd arrived that way lands in a second repository.
|
||||
let from_canonical =
|
||||
probe(&*host, &canonical).expect("the same repository, asked from its other name");
|
||||
assert_eq!(from_canonical.root, snap.root);
|
||||
|
||||
// The other two probes answer the same question and must not disagree
|
||||
// with it: `probe_status` keys `ScmData`, `probe_diff` keys the diff
|
||||
// overlay, and a disagreement between any two of them is a re-probe
|
||||
// that never settles.
|
||||
let status = match super::status::probe_status(&*host, &dir) {
|
||||
super::status::StatusProbe::Status(status) => *status,
|
||||
other => panic!("expected a repository, got {other:?}"),
|
||||
};
|
||||
assert_eq!(status.root, snap.root, "probe_status agrees with probe");
|
||||
assert_eq!(status.home, snap.home);
|
||||
|
||||
let diff = super::diff::probe_diff(&*host, &dir, &Default::default())
|
||||
.expect("an empty repository still has a diff");
|
||||
assert_eq!(diff.root, snap.root, "probe_diff agrees with probe");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,12 +845,12 @@ pub fn probe_status(host: &dyn Host, cwd: &Path) -> StatusProbe {
|
||||
}
|
||||
let paths = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
|
||||
let Some(root) = lines.next().map(PathBuf::from) else {
|
||||
let Some(root) = lines.next().map(super::git_path) else {
|
||||
return StatusProbe::Unreachable;
|
||||
};
|
||||
let git_dir = lines.next();
|
||||
let home = super::repo_home(&root, git_dir, lines.next());
|
||||
let Some(git_dir) = git_dir.map(PathBuf::from) else {
|
||||
let Some(git_dir) = git_dir.map(super::git_path) else {
|
||||
return StatusProbe::Unreachable;
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod kitty_graphics;
|
||||
pub mod logfile;
|
||||
pub mod machine;
|
||||
pub mod osc;
|
||||
pub mod path_spelling;
|
||||
pub mod proc;
|
||||
pub mod session;
|
||||
pub mod shells;
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! The one spelling tty7 stores a path on **this** machine in.
|
||||
//!
|
||||
//! The same directory reaches this process under three names on Windows and
|
||||
//! only two of them compare equal:
|
||||
//!
|
||||
//! - `C:\Users\x\repo` — what the OS, a shell and a pane's cwd all say;
|
||||
//! - `C:/Users/x/repo` — what Git for Windows says, whatever shell asked it:
|
||||
//! `rev-parse --show-toplevel` and `--git-common-dir` are MSYS2 paths and
|
||||
//! always come back with forward slashes;
|
||||
//! - `\\?\C:\Users\x\repo` — what [`std::fs::canonicalize`] says, because Rust
|
||||
//! asks the OS for the extended-length form.
|
||||
//!
|
||||
//! `Path` on Windows already forgives the first two of each other: it compares,
|
||||
//! hashes and prefix-matches by *component*, and both `/` and `\` end a
|
||||
//! component, so a drive letter's case is folded on the way past too. What it
|
||||
//! does not forgive is the third. `\\?\C:` parses as [`Prefix::VerbatimDisk`]
|
||||
//! and `C:` as [`Prefix::Disk`], those are different components, and so
|
||||
//! `\\?\C:\Users\x\repo != C:/Users/x/repo` — by equality, by hash, and by
|
||||
//! `starts_with`. Every cache in the SCM layer is keyed by exactly that
|
||||
//! comparison, so a root that came in past `canonicalize` and a root that came
|
||||
//! out of `git` name the same repository and share nothing.
|
||||
//!
|
||||
//! [`local_spelling`] is where that is settled, once, at the boundary a path
|
||||
//! is *created* at rather than at each of the places it is later compared.
|
||||
//! The spelling it lands on is the plain one — native separators, no
|
||||
//! extended-length prefix — because that is the one every other consumer
|
||||
//! wants: the Win32 shell's `ParseDisplayName` rejects both a mixed-separator
|
||||
//! path and a `\\?\` one, `git` takes either on its command line, and it is
|
||||
//! the only one of the three a person would recognise in a tooltip.
|
||||
//!
|
||||
//! Off Windows all three collapse: `/` is the separator, there is no
|
||||
//! extended-length form, and both functions here are the identity.
|
||||
//!
|
||||
//! [`Prefix::VerbatimDisk`]: std::path::Prefix::VerbatimDisk
|
||||
//! [`Prefix::Disk`]: std::path::Prefix::Disk
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
|
||||
/// Re-spells a path on **this** machine with the separators this OS expects.
|
||||
///
|
||||
/// On Windows the shell's `IShellFolder::ParseDisplayName` bails out with
|
||||
/// `E_INVALIDARG` on a mixed-separator path — a forward-slash prefix joined
|
||||
/// with backslash entries. The forward slashes get in from two routes: the
|
||||
/// shell's PWD (OSC 7 from Git Bash / MSYS bash reports `/`, and that string
|
||||
/// survives `Path::ancestors()` when the file tree walks up to find `.git`),
|
||||
/// and `git rev-parse --show-toplevel` from Git for Windows (MSYS2), which
|
||||
/// always prints `/` regardless of the calling shell.
|
||||
///
|
||||
/// **Only for paths on the machine this window runs on.** A remote host's
|
||||
/// `/home/u/src` is already native over there; re-spelling it would put a
|
||||
/// path on the clipboard that names nothing on either machine.
|
||||
///
|
||||
/// The rewrite runs on the path's own UTF-16 code units, not on a
|
||||
/// `to_string_lossy` copy of them. A Windows filename may hold unpaired
|
||||
/// surrogates, which `to_string_lossy` turns into `U+FFFD` — the returned
|
||||
/// path would then silently name a *different* file. `/` and `\` are ASCII,
|
||||
/// so a code unit equal to one of them is that character and never half of a
|
||||
/// surrogate pair, which is what makes the swap safe to do one unit at a time.
|
||||
#[cfg(windows)]
|
||||
pub fn native_separators(path: &Path) -> Cow<'_, Path> {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
||||
use std::path::PathBuf;
|
||||
|
||||
let os = path.as_os_str();
|
||||
// Nothing to fix — including every UNC (`\\wsl$\…`, `\\?\…`) and
|
||||
// already-native path — hands the caller's own path straight back.
|
||||
if !os.encode_wide().any(|unit| unit == SLASH) {
|
||||
return Cow::Borrowed(path);
|
||||
}
|
||||
let wide: Vec<u16> = os
|
||||
.encode_wide()
|
||||
.map(|unit| if unit == SLASH { BACKSLASH } else { unit })
|
||||
.collect();
|
||||
Cow::Owned(PathBuf::from(OsString::from_wide(&wide)))
|
||||
}
|
||||
|
||||
/// Off Windows the OS separator is already `/`, and a backslash in a path is
|
||||
/// an ordinary filename character — there is nothing to re-spell.
|
||||
#[cfg(not(windows))]
|
||||
pub fn native_separators(path: &Path) -> Cow<'_, Path> {
|
||||
Cow::Borrowed(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
const SLASH: u16 = b'/' as u16;
|
||||
#[cfg(windows)]
|
||||
const BACKSLASH: u16 = b'\\' as u16;
|
||||
|
||||
/// The spelling tty7 stores a local path in, so that two of them naming one
|
||||
/// directory are one key.
|
||||
///
|
||||
/// Native separators (see [`native_separators`]) *and* no extended-length
|
||||
/// prefix: `\\?\C:\x` becomes `C:\x` and `\\?\UNC\srv\share` becomes
|
||||
/// `\\srv\share`, which is the same path as far as every Win32 API is
|
||||
/// concerned and the only spelling `Path` will compare equal to the one a
|
||||
/// shell, a pane cwd or `git` reports.
|
||||
///
|
||||
/// What it deliberately does **not** do:
|
||||
///
|
||||
/// - **fold case.** `Path` already folds the drive letter, which is where
|
||||
/// Windows case instability actually lives; folding the rest would make two
|
||||
/// genuinely different names on a case-sensitive volume — or on a remote
|
||||
/// host, whose paths also pass through here unchanged off Windows — collide.
|
||||
/// - **trim a trailing separator.** `Path` already ignores one: `C:\x\` and
|
||||
/// `C:\x` are equal, hash alike and prefix-match each other.
|
||||
/// - **touch the disk.** This is a re-spelling, not a resolution: a junction,
|
||||
/// a `subst` drive or an 8.3 short name is left exactly as it arrived.
|
||||
/// `git` has already resolved its own answer, and a caller that wants the
|
||||
/// real path calls `Host::canonicalize`, which now lands here on its way
|
||||
/// out.
|
||||
/// - **rewrite anything but a drive or UNC verbatim path.** `\\?\pipe\…` and
|
||||
/// the other device namespaces have no plain form to fall back to.
|
||||
#[cfg(windows)]
|
||||
pub fn local_spelling(path: &Path) -> Cow<'_, Path> {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
||||
use std::path::PathBuf;
|
||||
|
||||
let native = native_separators(path);
|
||||
let wide: Vec<u16> = native.as_os_str().encode_wide().collect();
|
||||
let Some(bare) = strip_extended_length(&wide) else {
|
||||
return native;
|
||||
};
|
||||
Cow::Owned(PathBuf::from(OsString::from_wide(&bare)))
|
||||
}
|
||||
|
||||
/// Off Windows there is no second spelling to fold into the first.
|
||||
#[cfg(not(windows))]
|
||||
pub fn local_spelling(path: &Path) -> Cow<'_, Path> {
|
||||
Cow::Borrowed(path)
|
||||
}
|
||||
|
||||
/// [`local_spelling`], for a caller that is building the path anyway and has
|
||||
/// nothing to hand back borrowed.
|
||||
pub fn local_spelling_buf(path: impl AsRef<Path>) -> std::path::PathBuf {
|
||||
local_spelling(path.as_ref()).into_owned()
|
||||
}
|
||||
|
||||
/// The plain form of an extended-length path, or `None` when there is not one.
|
||||
///
|
||||
/// Split out so the rule is testable on literal UTF-16, which is the only way
|
||||
/// to write the surrogate case down and the only way a non-Windows developer
|
||||
/// ever sees either shape.
|
||||
#[cfg(windows)]
|
||||
fn strip_extended_length(wide: &[u16]) -> Option<Vec<u16>> {
|
||||
const VERBATIM: [u16; 4] = [BACKSLASH, BACKSLASH, b'?' as u16, BACKSLASH];
|
||||
const UNC: [u16; 4] = [b'U' as u16, b'N' as u16, b'C' as u16, BACKSLASH];
|
||||
|
||||
let rest = wide.strip_prefix(&VERBATIM)?;
|
||||
// `\\?\UNC\srv\share` → `\\srv\share`. The `\` that follows `UNC` is kept
|
||||
// and one more put in front of it, which is the pair a plain UNC path
|
||||
// opens with. Windows spells the segment `UNC` but accepts any case, so
|
||||
// match it the way the OS would.
|
||||
let head: Vec<u16> = rest
|
||||
.iter()
|
||||
.take(4)
|
||||
.map(|u| u16::from(u8::try_from(*u).unwrap_or(0).to_ascii_uppercase()))
|
||||
.collect();
|
||||
if head == UNC {
|
||||
let mut plain = vec![BACKSLASH];
|
||||
plain.extend_from_slice(&rest[3..]);
|
||||
return Some(plain);
|
||||
}
|
||||
// `\\?\C:\…` → `C:\…`, and `\\?\C:` on its own too. Anything else behind
|
||||
// the prefix is a device namespace with no plain form — leave it whole.
|
||||
let (drive, colon) = (*rest.first()?, *rest.get(1)?);
|
||||
let drive_letter = u8::try_from(drive).is_ok_and(|b| b.is_ascii_alphabetic());
|
||||
if drive_letter && colon == b':' as u16 && rest.get(2).is_none_or(|u| *u == BACKSLASH) {
|
||||
return Some(rest.to_vec());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_path_with_nothing_to_fix_is_handed_back_borrowed() {
|
||||
// No allocation for the overwhelmingly common case: a path already
|
||||
// spelled the way this machine spells one. That is every path in the
|
||||
// app once the boundaries below have done their work, so the cost of
|
||||
// asking again at a lookup is a scan and nothing else.
|
||||
let native: &[&str] = match cfg!(windows) {
|
||||
true => &["README.md", r"C:\code\repo", r"\\server\share\proj"],
|
||||
false => &["README.md", "/code/repo"],
|
||||
};
|
||||
for p in native {
|
||||
let got = local_spelling(Path::new(p));
|
||||
assert_eq!(got.as_ref(), Path::new(p), "{p:?}");
|
||||
assert!(matches!(got, Cow::Borrowed(_)), "{p:?} should not allocate");
|
||||
assert!(matches!(native_separators(Path::new(p)), Cow::Borrowed(_)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole point: the three spellings of one directory become one key.
|
||||
#[test]
|
||||
fn every_spelling_of_one_directory_lands_on_the_same_key() {
|
||||
let want = local_spelling_buf(Path::new(if cfg!(windows) {
|
||||
r"C:\Users\x\repo"
|
||||
} else {
|
||||
"/home/x/repo"
|
||||
}));
|
||||
let spellings: &[&str] = if cfg!(windows) {
|
||||
&[
|
||||
r"C:\Users\x\repo",
|
||||
"C:/Users/x/repo",
|
||||
r"\\?\C:\Users\x\repo",
|
||||
// Mixed, which is what `root.join(rel)` produces once a
|
||||
// forward-slash root has had a native component added to it.
|
||||
r"C:/Users/x\repo",
|
||||
]
|
||||
} else {
|
||||
&["/home/x/repo"]
|
||||
};
|
||||
for spelling in spellings {
|
||||
assert_eq!(
|
||||
local_spelling(Path::new(spelling)).as_ref(),
|
||||
want.as_path(),
|
||||
"{spelling:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What `Path` already does for us, pinned so a later "improvement" here
|
||||
/// cannot quietly start folding things it must not. These are the cases
|
||||
/// the #791 gate blamed for the SCM divergence; they were never the cause.
|
||||
#[test]
|
||||
fn path_equality_already_forgives_case_slashes_and_a_trailing_separator() {
|
||||
if !cfg!(windows) {
|
||||
return;
|
||||
}
|
||||
let root = Path::new(r"C:\Users\x\repo");
|
||||
for same in [
|
||||
"C:/Users/x/repo",
|
||||
r"c:\Users\x\repo",
|
||||
r"C:\Users\x\repo\",
|
||||
"C:/Users/x/repo/",
|
||||
] {
|
||||
assert_eq!(Path::new(same), root, "{same:?}");
|
||||
assert_eq!(local_spelling(Path::new(same)).as_ref(), root, "{same:?}");
|
||||
}
|
||||
// …and the one it does not, which is why this module exists.
|
||||
assert_ne!(Path::new(r"\\?\C:\Users\x\repo"), root);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_unc_verbatim_path_falls_back_to_its_plain_form() {
|
||||
assert_eq!(
|
||||
local_spelling(Path::new(r"\\?\UNC\server\share\proj")).as_ref(),
|
||||
Path::new(r"\\server\share\proj")
|
||||
);
|
||||
// Lowercase `unc` is the same namespace to Windows.
|
||||
assert_eq!(
|
||||
local_spelling(Path::new(r"\\?\unc\server\share")).as_ref(),
|
||||
Path::new(r"\\server\share")
|
||||
);
|
||||
// A plain UNC path is already plain, and must keep its leading `\\`.
|
||||
for p in [r"\\server\share\proj", r"\\wsl$\Ubuntu\home"] {
|
||||
let got = local_spelling(Path::new(p));
|
||||
assert_eq!(got.as_ref(), Path::new(p), "{p:?}");
|
||||
assert!(matches!(got, Cow::Borrowed(_)), "{p:?} should not allocate");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_device_namespace_has_no_plain_form_and_is_left_whole() {
|
||||
// `\\?\pipe\…` and `\\.\…` are not filesystem paths with a drive to
|
||||
// fall back to; rewriting either would name nothing.
|
||||
for p in [r"\\?\pipe\tty7", r"\\.\PhysicalDrive0", r"\\?\Volume{0}\x"] {
|
||||
assert_eq!(local_spelling(Path::new(p)).as_ref(), Path::new(p), "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_bare_verbatim_drive_keeps_its_root() {
|
||||
assert_eq!(
|
||||
local_spelling(Path::new(r"\\?\C:\")).as_ref(),
|
||||
Path::new(r"C:\")
|
||||
);
|
||||
assert_eq!(
|
||||
local_spelling(Path::new(r"\\?\C:")).as_ref(),
|
||||
Path::new("C:")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_non_ascii_component_survives_both_rewrites() {
|
||||
assert_eq!(
|
||||
local_spelling(Path::new(r"\\?\C:\Users\x\中文名\проект")).as_ref(),
|
||||
Path::new(r"C:\Users\x\中文名\проект")
|
||||
);
|
||||
assert_eq!(
|
||||
local_spelling(Path::new("C:/Users/x/中文名/проект")).as_ref(),
|
||||
Path::new(r"C:\Users\x\中文名\проект")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_name_a_string_cannot_hold_is_kept() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
||||
use std::path::PathBuf;
|
||||
|
||||
// `0xD800` is a lone high surrogate — legal in an NTFS name, and not
|
||||
// representable in a Rust `str`. Going through `to_string_lossy`
|
||||
// would swap it for `U+FFFD` and hand back a path naming a
|
||||
// *different* file. Working on the UTF-16 units keeps the name.
|
||||
let raw: Vec<u16> = r"\\?\C:\a"
|
||||
.encode_utf16()
|
||||
.chain([0xD800])
|
||||
.chain("/b".encode_utf16())
|
||||
.collect();
|
||||
let path = PathBuf::from(OsString::from_wide(&raw));
|
||||
let want: Vec<u16> = r"C:\a"
|
||||
.encode_utf16()
|
||||
.chain([0xD800])
|
||||
.chain(r"\b".encode_utf16())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
local_spelling(&path)
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.collect::<Vec<_>>(),
|
||||
want
|
||||
);
|
||||
// The round-trip this avoids really does destroy it.
|
||||
assert!(path.to_string_lossy().contains('\u{FFFD}'));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn off_windows_both_are_the_identity() {
|
||||
// A backslash in a Unix path is an ordinary filename character, and a
|
||||
// remote host's paths pass through this same code on a Windows client.
|
||||
for p in ["/home/u/tty7", r"C:\Users\dev", r"mixed/path\here"] {
|
||||
let got = local_spelling(Path::new(p));
|
||||
assert_eq!(got.as_ref(), Path::new(p), "{p:?}");
|
||||
assert!(matches!(got, Cow::Borrowed(_)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::core::codename::Names;
|
||||
use crate::core::git::git_path;
|
||||
use crate::host::Host;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -63,14 +64,14 @@ pub fn managed(host: &dyn Host, cwd: &Path) -> Option<ManagedWorktree> {
|
||||
if !cwd.ancestors().any(|a| a.ends_with(&suffix)) {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(git(host, &cwd, &["rev-parse", "--show-toplevel"]).ok()?);
|
||||
let path = git_path(&git(host, &cwd, &["rev-parse", "--show-toplevel"]).ok()?);
|
||||
let main_root = git(
|
||||
host,
|
||||
&path,
|
||||
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
)
|
||||
.ok()
|
||||
.map(PathBuf::from)?
|
||||
.map(|d| git_path(&d))?
|
||||
.parent()?
|
||||
.to_path_buf();
|
||||
if !path.starts_with(managed_root(host, &main_root)) {
|
||||
@@ -111,14 +112,14 @@ pub fn remove(host: &dyn Host, wt: &ManagedWorktree, force: bool) -> Result<(),
|
||||
fn repo_dir(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), String> {
|
||||
let repo_root = git(host, cwd, &["rev-parse", "--show-toplevel"])
|
||||
.map_err(|_| "not inside a git repository".to_string())?;
|
||||
let repo_root = PathBuf::from(repo_root);
|
||||
let repo_root = git_path(&repo_root);
|
||||
let main_root = git(
|
||||
host,
|
||||
cwd,
|
||||
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
)
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.map(|d| git_path(&d))
|
||||
.and_then(|d| d.parent().map(Path::to_path_buf))
|
||||
.unwrap_or_else(|| repo_root.clone());
|
||||
let dir = managed_root(host, &main_root);
|
||||
|
||||
@@ -177,9 +177,22 @@ impl Host for LocalHost {
|
||||
fs::read(p)
|
||||
}
|
||||
|
||||
/// The real path behind `p`, in the spelling the rest of tty7 keys by.
|
||||
///
|
||||
/// `fs::canonicalize` answers with the extended-length form on Windows —
|
||||
/// `\\?\C:\Users\x\repo` — which is a different `Prefix` component from
|
||||
/// the `C:\Users\x\repo` a shell, a pane cwd and `git` all report, and so
|
||||
/// compares unequal, hashes differently and fails `starts_with` against
|
||||
/// every one of them. `\\?\` is a Win32 API escape hatch rather than part
|
||||
/// of the path's identity, so it comes off here, at the one boundary that
|
||||
/// produces it. What the call is actually *for* — resolving a junction, a
|
||||
/// `subst` drive, an 8.3 short name or a symlink — is untouched. See
|
||||
/// [`crate::core::path_spelling`].
|
||||
fn canonicalize(&self, p: &Path) -> io::Result<PathBuf> {
|
||||
guard_off_ui();
|
||||
fs::canonicalize(p)
|
||||
Ok(crate::core::path_spelling::local_spelling_buf(
|
||||
fs::canonicalize(p)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn search(
|
||||
|
||||
+185
-14
@@ -1,9 +1,24 @@
|
||||
use std::borrow::Cow;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub use crate::core::git::{GitStatus, RepoSnapshot, probe};
|
||||
use crate::ui::host_ops::{ByHost, HostId, InFlight};
|
||||
|
||||
/// The spelling a directory is keyed by in here.
|
||||
///
|
||||
/// This cache is where a repository gets its identity: `roots` maps a cwd to a
|
||||
/// root, and `homes`, `status` and `last_probe` are then all keyed by that
|
||||
/// root, which is in turn the key `ScmData` and the diff overlay use. The two
|
||||
/// halves of a lookup arrive from different places — a cwd from the pane, a
|
||||
/// root from `git`, and either one possibly past `fs::canonicalize` — so this
|
||||
/// is the one place that has to insist they agree. Off Windows, and for every
|
||||
/// path that already spells itself the OS's way, it is a borrow and nothing
|
||||
/// else. See [`tty7_core::core::path_spelling`].
|
||||
fn key(path: &Path) -> Cow<'_, Path> {
|
||||
tty7_core::core::path_spelling::local_spelling(path)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GitStatusCache {
|
||||
roots: ByHost<PathBuf, Option<PathBuf>>,
|
||||
@@ -17,12 +32,12 @@ impl gpui::Global for GitStatusCache {}
|
||||
|
||||
impl GitStatusCache {
|
||||
pub fn status_for(&self, host: HostId, cwd: &Path) -> Option<GitStatus> {
|
||||
let root = self.roots.get(host, cwd)?.as_ref()?;
|
||||
self.status.get(host, root).cloned()
|
||||
let root = self.roots.get(host, &*key(cwd))?.as_ref()?;
|
||||
self.status.get(host, root.as_path()).cloned()
|
||||
}
|
||||
|
||||
pub fn known_repo_for(&self, host: HostId, cwd: &Path) -> Option<Option<PathBuf>> {
|
||||
let root = self.roots.get(host, cwd)?;
|
||||
let root = self.roots.get(host, &*key(cwd))?;
|
||||
Some(root.as_ref().map(|root| {
|
||||
self.homes
|
||||
.get(host, root)
|
||||
@@ -38,7 +53,7 @@ impl GitStatusCache {
|
||||
/// a "which project is this" question wants. This answers with the root,
|
||||
/// which is the key everything git-shaped is stored under.
|
||||
pub fn repo_root_for(&self, host: HostId, cwd: &Path) -> Option<&Path> {
|
||||
self.roots.get(host, cwd)?.as_deref()
|
||||
self.roots.get(host, &*key(cwd))?.as_deref()
|
||||
}
|
||||
|
||||
/// Forget a machine we have stopped talking to, so a reconnect starts from
|
||||
@@ -51,7 +66,7 @@ impl GitStatusCache {
|
||||
}
|
||||
|
||||
pub fn begin_probe(&mut self, host: HostId, cwd: &Path) -> bool {
|
||||
let key = (host, cwd.to_path_buf());
|
||||
let key = (host, key(cwd).into_owned());
|
||||
if self.probes.begin(key.clone()) {
|
||||
true
|
||||
} else {
|
||||
@@ -66,22 +81,25 @@ impl GitStatusCache {
|
||||
cwd: &Path,
|
||||
min_interval: Duration,
|
||||
) -> bool {
|
||||
let cwd = key(cwd);
|
||||
if self.probes.is_pending(&(host, cwd.to_path_buf())) {
|
||||
return false;
|
||||
}
|
||||
let key = self.throttle_key(host, cwd).to_path_buf();
|
||||
let throttle = self.throttle_key(host, &cwd).to_path_buf();
|
||||
if self
|
||||
.last_probe
|
||||
.get(host, key.as_path())
|
||||
.get(host, throttle.as_path())
|
||||
.is_some_and(|at| at.elapsed() < min_interval)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.last_probe.insert(host, key, Instant::now());
|
||||
self.probes.begin((host, cwd.to_path_buf()));
|
||||
self.last_probe.insert(host, throttle, Instant::now());
|
||||
self.probes.begin((host, cwd.into_owned()));
|
||||
true
|
||||
}
|
||||
|
||||
/// `cwd` is already in the cache's own spelling — every caller of this one
|
||||
/// has been past [`key`].
|
||||
fn throttle_key<'a>(&'a self, host: HostId, cwd: &'a Path) -> &'a Path {
|
||||
match self.roots.get(host, cwd) {
|
||||
Some(Some(root)) => root,
|
||||
@@ -122,7 +140,8 @@ impl GitStatusCache {
|
||||
branch: &str,
|
||||
counts: Option<(u32, u32)>,
|
||||
) -> bool {
|
||||
let Some(status) = self.status.get(host, root) else {
|
||||
let root = key(root);
|
||||
let Some(status) = self.status.get(host, &*root) else {
|
||||
return false;
|
||||
};
|
||||
let (added, removed) = counts.unwrap_or((status.added, status.removed));
|
||||
@@ -131,7 +150,7 @@ impl GitStatusCache {
|
||||
}
|
||||
self.status.insert(
|
||||
host,
|
||||
root.to_path_buf(),
|
||||
root.into_owned(),
|
||||
GitStatus {
|
||||
branch: branch.to_string(),
|
||||
added,
|
||||
@@ -147,12 +166,21 @@ impl GitStatusCache {
|
||||
cwd: &Path,
|
||||
snapshot: Option<RepoSnapshot>,
|
||||
) -> bool {
|
||||
// A snapshot arrives spelled by `git`, the cwd by whoever asked for
|
||||
// the probe. Both land in the cache's own spelling or the root a
|
||||
// status is filed under is not the root the next lookup asks for.
|
||||
let cwd = key(cwd);
|
||||
let snapshot = snapshot.map(|snap| RepoSnapshot {
|
||||
root: key(&snap.root).into_owned(),
|
||||
home: key(&snap.home).into_owned(),
|
||||
..snap
|
||||
});
|
||||
let rerun = !self.probes.finish(&(host, cwd.to_path_buf()));
|
||||
let key = match &snapshot {
|
||||
let throttle = match &snapshot {
|
||||
Some(snap) => snap.root.clone(),
|
||||
None => self.throttle_key(host, cwd).to_path_buf(),
|
||||
None => self.throttle_key(host, &cwd).to_path_buf(),
|
||||
};
|
||||
self.last_probe.insert(host, key, Instant::now());
|
||||
self.last_probe.insert(host, throttle, Instant::now());
|
||||
match snapshot {
|
||||
Some(snap) => {
|
||||
let (added, removed) = snap.counts.unwrap_or_else(|| {
|
||||
@@ -465,6 +493,149 @@ mod tests {
|
||||
assert!(cache.begin_probe(L, cwd));
|
||||
}
|
||||
|
||||
/// Every way one directory can be spelled on the way into this cache is
|
||||
/// one key.
|
||||
///
|
||||
/// Ungated on purpose. The spellings below are the ones Windows actually
|
||||
/// produces — a pane says `C:\repo`, `git rev-parse` says `C:/repo`,
|
||||
/// `fs::canonicalize` says `\\?\C:\repo` — and on Unix they collapse to
|
||||
/// one, so this costs nothing there and is the whole test here. Gating it
|
||||
/// to unix is what let the divergence live: the *only* platform that has
|
||||
/// three spellings was the only one not running the comparison.
|
||||
#[test]
|
||||
fn one_directory_spelled_three_ways_is_one_repository() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let (a, b, c) = match cfg!(windows) {
|
||||
true => (r"C:\code\repo", "C:/code/repo", r"\\?\C:\code\repo"),
|
||||
false => ("/code/repo", "/code/repo", "/code/repo"),
|
||||
};
|
||||
let (a, b, c) = (Path::new(a), Path::new(b), Path::new(c));
|
||||
|
||||
// Probed under the resolved spelling, which is what a caller that went
|
||||
// through `Host::canonicalize` has.
|
||||
cache.finish_probe(L, c, Some(snap(c.to_str().unwrap(), "main", Some((9, 9)))));
|
||||
|
||||
for spelling in [a, b, c] {
|
||||
assert_eq!(
|
||||
cache.repo_root_for(L, spelling),
|
||||
Some(a),
|
||||
"{spelling:?} names the repository the others do"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.known_repo_for(L, spelling),
|
||||
Some(Some(a.to_path_buf())),
|
||||
"{spelling:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.status_for(L, spelling).unwrap().branch,
|
||||
"main",
|
||||
"{spelling:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The diff overlay's spin, in the cache underneath it.
|
||||
///
|
||||
/// `install_diff_snapshot` hands the branch it just read back with the
|
||||
/// root `git rev-parse` printed, while the status it is correcting was
|
||||
/// filed under the root whoever probed had. When those two spellings miss
|
||||
/// each other the correction is dropped, the overlay's next
|
||||
/// `maybe_refresh` finds the same disagreement it just tried to settle,
|
||||
/// and it re-reads the diff — `load=ready loading=true`, two `git`
|
||||
/// processes a lap, for as long as the overlay is open.
|
||||
#[test]
|
||||
fn a_diff_read_settles_a_branch_it_learned_the_root_of_from_git() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let (probed, from_git) = match cfg!(windows) {
|
||||
true => (r"\\?\C:\code\repo", "C:/code/repo"),
|
||||
false => ("/code/repo", "/code/repo"),
|
||||
};
|
||||
cache.finish_probe(
|
||||
L,
|
||||
Path::new(probed),
|
||||
Some(snap(probed, "a-branch-this-repo-has-left", Some((99, 99)))),
|
||||
);
|
||||
|
||||
assert!(
|
||||
cache.note_diff_read(L, Path::new(from_git), "main", Some((1, 0))),
|
||||
"the correction has to land, or the overlay reprobes forever"
|
||||
);
|
||||
let got = cache.status_for(L, Path::new(probed)).unwrap();
|
||||
assert_eq!(got.branch, "main");
|
||||
assert_eq!((got.added, got.removed), (1, 0));
|
||||
assert!(
|
||||
!cache.note_diff_read(L, Path::new(from_git), "main", Some((1, 0))),
|
||||
"and the second lap has nothing left to say — this is what ends it"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same loop, end to end against a repository `git` actually created,
|
||||
/// because the literals above only prove the rule and not that this is the
|
||||
/// rule the real answers need.
|
||||
///
|
||||
/// This is the shape the diff overlay runs every frame: a status filed
|
||||
/// under the cwd a probe was asked about, then a diff read filed under the
|
||||
/// root `git rev-parse` printed. No window and no gpui, so it runs
|
||||
/// everywhere the test binary does.
|
||||
#[test]
|
||||
fn a_real_repository_files_its_probe_and_its_diff_under_one_root() {
|
||||
use tty7_core::core::git::diff::{DiffRequest, probe_diff};
|
||||
|
||||
let host = tty7_core::host::local::LocalHost::new();
|
||||
let dir = std::env::temp_dir().join(format!("tty7-one-root-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let ok = host
|
||||
.git(&dir, &["init", "--quiet"])
|
||||
.is_ok_and(|o| o.success());
|
||||
if !ok {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return; // no git on this machine
|
||||
}
|
||||
for cfg in [
|
||||
["config", "user.email", "t@x"].as_slice(),
|
||||
["config", "user.name", "t"].as_slice(),
|
||||
] {
|
||||
assert!(host.git(&dir, cfg).is_ok_and(|o| o.success()));
|
||||
}
|
||||
std::fs::write(dir.join("a.rs"), "fn main() {}\n").unwrap();
|
||||
assert!(host.git(&dir, &["add", "-A"]).is_ok_and(|o| o.success()));
|
||||
assert!(
|
||||
host.git(&dir, &["commit", "--quiet", "-m", "one"])
|
||||
.is_ok_and(|o| o.success())
|
||||
);
|
||||
std::fs::write(dir.join("a.rs"), "fn main() { /* edited */ }\n").unwrap();
|
||||
|
||||
// The cwd a pane reports can have been past `Host::canonicalize`; the
|
||||
// root a diff carries never has been.
|
||||
let cwd = host.canonicalize(&dir).expect("the scratch dir resolves");
|
||||
let mut cache = GitStatusCache::default();
|
||||
let snapshot = crate::core::git::probe(&*host, &cwd).expect("a repository is here");
|
||||
cache.finish_probe(L, &cwd, Some(snapshot));
|
||||
assert_eq!(
|
||||
cache.repo_root_for(L, &cwd),
|
||||
Some(cwd.as_path()),
|
||||
"the probed root is the directory the cache was asked about"
|
||||
);
|
||||
|
||||
let diff = probe_diff(&*host, &cwd, &DiffRequest::default()).expect("a diff is readable");
|
||||
assert_eq!(diff.root, cwd, "and the diff names that same directory");
|
||||
// A branch switched outside tty7 is what makes this correction the
|
||||
// thing that ends the overlay's loop rather than a no-op: the read has
|
||||
// to land the first time and have nothing to say the second.
|
||||
assert!(
|
||||
cache.note_diff_read(L, &diff.root, "moved-on", Some((0, 0))),
|
||||
"a diff read filed under git's root must reach the probe's status"
|
||||
);
|
||||
assert_eq!(cache.status_for(L, &cwd).unwrap().branch, "moved-on");
|
||||
assert!(
|
||||
!cache.note_diff_read(L, &diff.root, "moved-on", Some((0, 0))),
|
||||
"and the second lap says nothing — this is what ends the loop"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn throttle_collapses_subdirectories_of_one_repo() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
|
||||
+14
-43
@@ -69,56 +69,27 @@ fn normalized(s: &str) -> String {
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Re-spells a path on **this** machine with the separators this OS expects.
|
||||
/// Re-spells a path on **this** machine with the separators this OS expects,
|
||||
/// so the Win32 shell will take it.
|
||||
///
|
||||
/// On Windows the shell's `IShellFolder::ParseDisplayName` bails out with
|
||||
/// `E_INVALIDARG` on a mixed-separator path — a forward-slash prefix joined
|
||||
/// with backslash entries. The forward slashes get in from two routes: the
|
||||
/// shell's PWD (OSC 7 from Git Bash / MSYS bash reports `/`, and that string
|
||||
/// survives `Path::ancestors()` when the file tree walks up to find `.git`),
|
||||
/// and `git rev-parse --show-toplevel` from Git for Windows (MSYS2), which
|
||||
/// always prints `/` regardless of the calling shell. `reveal_path` swallows
|
||||
/// that failure (it only logs), so handing it native separators is what makes
|
||||
/// "open folder" actually open.
|
||||
/// `IShellFolder::ParseDisplayName` bails out with `E_INVALIDARG` on a
|
||||
/// mixed-separator path — a forward-slash prefix joined with backslash
|
||||
/// entries — and `reveal_path` swallows that failure (it only logs), so
|
||||
/// handing it native separators is what makes "open folder" actually open.
|
||||
///
|
||||
/// The rule itself lives in [`tty7_core::core::path_spelling`], next to the
|
||||
/// prefix rule the SCM caches need, because a path spelled two ways is one
|
||||
/// problem and it must not have two answers in two crates. This is the
|
||||
/// separators half on its own: a `\\?\` path is already something
|
||||
/// `ParseDisplayName` will not take, and re-spelling one here would be a
|
||||
/// silent change of subject rather than a fix.
|
||||
///
|
||||
/// **Only for paths on the machine this window runs on.** A remote host's
|
||||
/// `/home/u/src` is already native over there; re-spelling it would put a
|
||||
/// path on the clipboard that names nothing on either machine. Every caller
|
||||
/// sits behind a locality check for that reason.
|
||||
///
|
||||
/// The rewrite runs on the path's own UTF-16 code units, not on a
|
||||
/// `to_string_lossy` copy of them. A Windows filename may hold unpaired
|
||||
/// surrogates, which `to_string_lossy` turns into `U+FFFD` — the returned
|
||||
/// path would then silently name a *different* file, and reveal would open
|
||||
/// nothing without reporting why. `/` and `\` are ASCII, so a code unit
|
||||
/// equal to one of them is that character and never half of a surrogate
|
||||
/// pair, which is what makes the swap safe to do one unit at a time.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn native_separators(path: &Path) -> Cow<'_, Path> {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
||||
|
||||
const SLASH: u16 = b'/' as u16;
|
||||
const BACKSLASH: u16 = b'\\' as u16;
|
||||
|
||||
let os = path.as_os_str();
|
||||
// Nothing to fix — including every UNC (`\\wsl$\…`, `\\?\…`) and
|
||||
// already-native path — hands the caller's own path straight back.
|
||||
if !os.encode_wide().any(|unit| unit == SLASH) {
|
||||
return Cow::Borrowed(path);
|
||||
}
|
||||
let wide: Vec<u16> = os
|
||||
.encode_wide()
|
||||
.map(|unit| if unit == SLASH { BACKSLASH } else { unit })
|
||||
.collect();
|
||||
Cow::Owned(PathBuf::from(OsString::from_wide(&wide)))
|
||||
}
|
||||
|
||||
/// Off Windows the OS separator is already `/`, and a backslash in a path is
|
||||
/// an ordinary filename character — there is nothing to re-spell.
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) fn native_separators(path: &Path) -> Cow<'_, Path> {
|
||||
Cow::Borrowed(path)
|
||||
tty7_core::core::path_spelling::native_separators(path)
|
||||
}
|
||||
|
||||
/// Shortens `path` to start from `~` when it is (inside) `home` — the home
|
||||
|
||||
+5
-1
@@ -1255,12 +1255,16 @@ impl Tty7App {
|
||||
},
|
||||
move |this, out, cx| {
|
||||
this.scm.root_lookups.remove(&key);
|
||||
// In the spelling everything else keys by: Git for Windows
|
||||
// answers `C:/Users/…` and this root is what the panel, the
|
||||
// commit detail and `ScmData` all compare against a path the
|
||||
// OS spelled. See `tty7_core::core::path_spelling`.
|
||||
let root = out
|
||||
.as_deref()
|
||||
.and_then(|s| s.lines().next())
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(PathBuf::from);
|
||||
.map(tty7_core::core::path_spelling::local_spelling_buf);
|
||||
this.scm.roots.insert(key, (Instant::now(), root));
|
||||
cx.notify();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user