use std::borrow::Cow;
use std::fs;
use std::path::{Component, Path, PathBuf};
use crate::path_icase::worktree_path_for_index_entry;
use crate::pathspec::has_glob_chars;
use crate::unicode_normalization::resolve_worktree_path_for_staging;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GitPathError {
EscapesRoot,
InvalidRelativeUrl,
}
#[inline]
fn is_dir_sep(c: u8) -> bool {
c == b'/'
}
pub fn normalize_path_copy(src: &str) -> Result<String, GitPathError> {
let is_abs = src.starts_with('/');
let raw_ends_dir = {
let stripped = src.trim_end_matches('/');
stripped.ends_with("/.")
|| stripped.ends_with("/..")
|| src.ends_with('/')
|| src == "."
|| src == ".."
};
let trailing_slash = raw_ends_dir && !src.is_empty();
let mut stack: Vec<String> = Vec::new();
let bytes = src.as_bytes();
let mut i = 0usize;
if is_abs {
i = 1;
}
while i < bytes.len() {
while i < bytes.len() && bytes[i] == b'/' {
i += 1;
}
if i >= bytes.len() {
break;
}
let start = i;
while i < bytes.len() && bytes[i] != b'/' {
i += 1;
}
let part = &src[start..i];
if part == "." {
continue;
}
if part == ".." {
if stack.pop().is_none() {
return Err(GitPathError::EscapesRoot);
}
} else {
stack.push(part.to_string());
}
}
let mut out = if is_abs {
if stack.is_empty() {
"/".to_string()
} else {
"/".to_string() + &stack.join("/")
}
} else if stack.is_empty() {
String::new()
} else {
stack.join("/")
};
if trailing_slash && !out.is_empty() && !out.ends_with('/') {
out.push('/');
}
Ok(out)
}
fn chomp_trailing_dir_sep(path: &[u8], mut len: usize) -> usize {
while len > 0 && is_dir_sep(path[len - 1]) {
len -= 1;
}
len
}
pub fn strip_path_suffix(path: &str, suffix: &str) -> Option<String> {
let path = path.as_bytes();
let suffix = suffix.as_bytes();
let mut path_len = path.len();
let mut suffix_len = suffix.len();
while suffix_len > 0 {
if path_len == 0 {
return None;
}
if is_dir_sep(path[path_len - 1]) {
if !is_dir_sep(suffix[suffix_len - 1]) {
return None;
}
path_len = chomp_trailing_dir_sep(path, path_len);
suffix_len = chomp_trailing_dir_sep(suffix, suffix_len);
} else if path[path_len - 1] != suffix[suffix_len - 1] {
return None;
} else {
path_len -= 1;
suffix_len -= 1;
}
}
if path_len > 0 && !is_dir_sep(path[path_len - 1]) {
return None;
}
let off = chomp_trailing_dir_sep(path, path_len);
Some(String::from_utf8_lossy(&path[..off]).into_owned())
}
pub fn longest_ancestor_length(path: &str, prefixes_colon_sep: &str) -> Result<i32, GitPathError> {
let path = normalize_path_copy(path)?;
if path == "/" {
return Ok(-1);
}
let mut max_len: i64 = -1;
for ceil_raw in prefixes_colon_sep.split(':') {
if ceil_raw.is_empty() {
continue;
}
let ceil = normalize_path_copy(ceil_raw)?;
let mut len = ceil.len();
if len > 0 && ceil.as_bytes()[len - 1] == b'/' {
len -= 1;
}
let p = path.as_bytes();
let c = ceil.as_bytes();
if len > p.len() || len > c.len() || p[..len] != c[..len] {
continue;
}
if len == p.len() || p[len] != b'/' || p.get(len + 1).is_none() {
continue;
}
if len as i64 > max_len {
max_len = len as i64;
}
}
Ok(max_len as i32)
}
fn have_same_root(path1: &str, path2: &str) -> bool {
let abs1 = path1.starts_with('/');
let abs2 = path2.starts_with('/');
(abs1 && abs2) || (!abs1 && !abs2)
}
pub fn relative_path<'a>(in_path: &'a str, prefix: &'a str, sb: &'a mut String) -> Option<&'a str> {
let in_len = in_path.len();
let prefix_len = prefix.len();
let mut in_off = 0usize;
let mut prefix_off = 0usize;
let mut i = 0usize;
let mut j = 0usize;
if in_len == 0 {
return Some("./");
}
if prefix_len == 0 {
return Some(in_path);
}
if !have_same_root(in_path, prefix) {
return Some(in_path);
}
let in_b = in_path.as_bytes();
let pre_b = prefix.as_bytes();
while i < prefix_len && j < in_len && pre_b[i] == in_b[j] {
if is_dir_sep(pre_b[i]) {
while i < prefix_len && is_dir_sep(pre_b[i]) {
i += 1;
}
while j < in_len && is_dir_sep(in_b[j]) {
j += 1;
}
prefix_off = i;
in_off = j;
} else {
i += 1;
j += 1;
}
}
if i >= prefix_len && prefix_off < prefix_len {
if j >= in_len {
in_off = in_len;
} else if is_dir_sep(in_b[j]) {
while j < in_len && is_dir_sep(in_b[j]) {
j += 1;
}
in_off = j;
} else {
i = prefix_off;
}
} else if j >= in_len && in_off < in_len && is_dir_sep(pre_b[i]) {
while i < prefix_len && is_dir_sep(pre_b[i]) {
i += 1;
}
in_off = in_len;
}
let in_suffix = &in_path[in_off..];
let in_suffix_len = in_suffix.len();
if i >= prefix_len {
if in_suffix_len == 0 {
return Some("./");
}
return Some(in_suffix);
}
sb.clear();
sb.reserve(in_suffix_len.saturating_add(prefix_len * 3));
while i < prefix_len {
if is_dir_sep(pre_b[i]) {
sb.push_str("../");
while i < prefix_len && is_dir_sep(pre_b[i]) {
i += 1;
}
continue;
}
i += 1;
}
if prefix_len > 0 && !is_dir_sep(pre_b[prefix_len - 1]) {
sb.push_str("../");
}
sb.push_str(in_suffix);
Some(sb.as_str())
}
fn find_last_dir_sep(path: &str) -> Option<usize> {
path.rfind('/')
}
fn chop_last_dir(remoteurl: &mut String, is_relative: bool) -> Result<bool, GitPathError> {
if let Some(pos) = find_last_dir_sep(remoteurl.as_str()) {
remoteurl.truncate(pos);
return Ok(false);
}
if let Some(pos) = remoteurl.rfind(':') {
remoteurl.truncate(pos);
return Ok(true);
}
if is_relative || remoteurl == "." {
return Err(GitPathError::InvalidRelativeUrl);
}
*remoteurl = ".".to_string();
Ok(false)
}
fn url_is_local_not_ssh(url: &str) -> bool {
crate::transport_path::url_is_local_not_ssh(url)
}
fn starts_with_dot_slash_native(s: &str) -> bool {
s.starts_with("./")
}
fn starts_with_dot_dot_slash_native(s: &str) -> bool {
s.starts_with("../")
}
fn ends_with_slash(url: &str) -> bool {
url.ends_with('/')
}
pub fn relative_url(
remote_url: &str,
url: &str,
up_path: Option<&str>,
) -> Result<String, GitPathError> {
if !url_is_local_not_ssh(url) || url.starts_with('/') {
return Ok(url.to_string());
}
let mut remoteurl = remote_url.to_string();
let len = remoteurl.len();
if len == 0 {
return Err(GitPathError::InvalidRelativeUrl);
}
if remoteurl.ends_with('/') {
remoteurl.truncate(len - 1);
}
let is_relative = if !url_is_local_not_ssh(&remoteurl) || remoteurl.starts_with('/') {
false
} else {
if !starts_with_dot_slash_native(&remoteurl)
&& !starts_with_dot_dot_slash_native(&remoteurl)
{
remoteurl = format!("./{remoteurl}");
}
true
};
let mut url_rest = url;
let mut colonsep = false;
while !url_rest.is_empty() {
if starts_with_dot_dot_slash_native(url_rest) {
url_rest = &url_rest[3..];
let seg = chop_last_dir(&mut remoteurl, is_relative)?;
colonsep |= seg;
} else if starts_with_dot_slash_native(url_rest) {
url_rest = &url_rest[2..];
} else {
break;
}
}
let sep = if colonsep { ":" } else { "/" };
let mut combined = format!("{remoteurl}{sep}{url_rest}");
if ends_with_slash(url) && combined.ends_with('/') {
combined.pop();
}
let out = if starts_with_dot_slash_native(&combined) {
combined[2..].to_string()
} else {
combined
};
match up_path {
Some(up) if is_relative => Ok(format!("{up}{out}")),
_ => Ok(out),
}
}
#[must_use]
pub fn is_absolute_path_unix(path: &str) -> bool {
path.starts_with('/')
}
#[must_use]
pub fn cleanup_path(path: &str) -> &str {
if let Some(rest) = path.strip_prefix("./") {
rest.trim_start_matches('/')
} else {
path
}
}
#[must_use]
pub fn git_path_relative_component(path: &str) -> &str {
let trimmed = path.strip_prefix('/').unwrap_or(path);
cleanup_path(trimmed)
}
struct CommonDir {
is_dir: bool,
is_common: bool,
path: &'static str,
}
const COMMON_LIST: &[CommonDir] = &[
CommonDir {
is_dir: true,
is_common: true,
path: "branches",
},
CommonDir {
is_dir: true,
is_common: true,
path: "common",
},
CommonDir {
is_dir: true,
is_common: true,
path: "hooks",
},
CommonDir {
is_dir: true,
is_common: true,
path: "info",
},
CommonDir {
is_dir: false,
is_common: false,
path: "info/sparse-checkout",
},
CommonDir {
is_dir: true,
is_common: true,
path: "logs",
},
CommonDir {
is_dir: false,
is_common: false,
path: "logs/HEAD",
},
CommonDir {
is_dir: true,
is_common: false,
path: crate::refs::store::paths::LOGS_REFS_BISECT,
},
CommonDir {
is_dir: true,
is_common: false,
path: crate::refs::store::paths::LOGS_REFS_REWRITTEN,
},
CommonDir {
is_dir: true,
is_common: false,
path: crate::refs::store::paths::LOGS_REFS_WORKTREE,
},
CommonDir {
is_dir: true,
is_common: true,
path: "lost-found",
},
CommonDir {
is_dir: true,
is_common: true,
path: "objects",
},
CommonDir {
is_dir: true,
is_common: true,
path: "refs",
},
CommonDir {
is_dir: true,
is_common: false,
path: "refs/bisect",
},
CommonDir {
is_dir: true,
is_common: false,
path: "refs/rewritten",
},
CommonDir {
is_dir: true,
is_common: false,
path: "refs/worktree",
},
CommonDir {
is_dir: true,
is_common: true,
path: "remotes",
},
CommonDir {
is_dir: true,
is_common: true,
path: "worktrees",
},
CommonDir {
is_dir: true,
is_common: true,
path: "rr-cache",
},
CommonDir {
is_dir: true,
is_common: true,
path: "svn",
},
CommonDir {
is_dir: false,
is_common: true,
path: "config",
},
CommonDir {
is_dir: false,
is_common: true,
path: "gc.pid",
},
CommonDir {
is_dir: false,
is_common: true,
path: crate::refs::store::paths::packed_refs_rel(),
},
CommonDir {
is_dir: false,
is_common: true,
path: "shallow",
},
];
fn check_common(entry: &CommonDir, unmatched: &[u8]) -> Option<bool> {
let first = unmatched.first().copied();
if entry.is_dir && (first.is_none() || first == Some(b'/')) {
return Some(entry.is_common);
}
if !entry.is_dir && first.is_none() {
return Some(entry.is_common);
}
None
}
fn trie_find_common(key: &[u8]) -> Option<bool> {
let norm = normalize_double_slashes(key);
let mut matches: Vec<&CommonDir> = COMMON_LIST
.iter()
.filter(|e| key_has_prefix_node(&norm, e.path.as_bytes()))
.collect();
matches.sort_by_key(|a| std::cmp::Reverse(a.path.len()));
for entry in matches {
let plen = entry.path.len();
let unmatched = &norm[plen..];
if let Some(verdict) = check_common(entry, unmatched) {
return Some(verdict);
}
}
None
}
fn normalize_double_slashes(key: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(key.len());
let mut prev_slash = false;
for &b in key {
if b == b'/' {
if !prev_slash {
out.push(b);
}
prev_slash = true;
} else {
out.push(b);
prev_slash = false;
}
}
out
}
fn key_has_prefix_node(key: &[u8], node: &[u8]) -> bool {
if key.len() < node.len() || &key[..node.len()] != node {
return false;
}
matches!(key.get(node.len()), None | Some(b'/'))
}
#[must_use]
pub fn is_common_git_path(rel: &str) -> bool {
let stripped = rel.strip_suffix(".lock").unwrap_or(rel);
matches!(trie_find_common(stripped.as_bytes()), Some(true))
}
#[must_use]
pub fn real_path_resolving(path: &str) -> PathBuf {
let abs = if path.starts_with('/') {
path.to_string()
} else {
let cwd = crate::environment::Environment::empty().cwd.clone();
let joined = format!("{}/{}", cwd.display(), path);
normalize_path_copy(&joined).unwrap_or(joined)
};
let p = Path::new(&abs);
if let Ok(c) = p.canonicalize() {
return c;
}
let mut cur = PathBuf::from("/");
for part in abs.trim_start_matches('/').split('/') {
if part.is_empty() {
continue;
}
cur.push(part);
if let Ok(c) = cur.canonicalize() {
cur = c;
} else if let Ok(target) = std::fs::read_link(&cur) {
cur.pop();
cur.push(target);
if let Ok(c) = cur.canonicalize() {
cur = c;
}
}
}
if cur.exists() {
return cur;
}
let mut base = cur.clone();
let mut missing = Vec::new();
while !base.as_os_str().is_empty() && !base.exists() {
missing.push(base.file_name().unwrap_or_default().to_owned());
if !base.pop() {
break;
}
}
if base.as_os_str().is_empty() {
base = PathBuf::from("/");
}
let Ok(mut resolved) = base.canonicalize() else {
return cur;
};
while let Some(name) = missing.pop() {
resolved.push(name);
}
resolved
}
pub fn abspath_part_inside_repo(path: &str, work_tree: &Path) -> Option<String> {
let normalized = normalize_path_copy(path).ok()?;
if !normalized.starts_with('/') {
return None;
}
let wt_display = work_tree.to_string_lossy();
let wt_trim: &str = if wt_display == "/" {
"/"
} else {
wt_display.trim_end_matches('/')
};
let wt_len = wt_trim.len();
let p = normalized.as_str();
let len = p.len();
if wt_len <= len && p.starts_with(wt_trim) {
if len > wt_len && p.as_bytes()[wt_len] == b'/' {
return Some(p[wt_len + 1..].to_string());
}
if len == wt_len {
return Some(String::new());
}
if wt_len > 0 && wt_trim.as_bytes()[wt_len - 1] == b'/' {
return Some(p[wt_len..].trim_start_matches('/').to_string());
}
}
let wt_canon = path_for_disk_compare(work_tree);
let mut cum = String::new();
for seg in p.split('/').filter(|s| !s.is_empty()) {
cum.push('/');
cum.push_str(seg);
let rp = path_for_disk_compare(Path::new(&cum));
if rp == wt_canon {
if p.len() == cum.len() {
return Some(String::new());
}
if p.as_bytes().get(cum.len()) == Some(&b'/') {
return Some(p[cum.len() + 1..].to_string());
}
}
}
let full = path_for_disk_compare(Path::new(p));
if full == wt_canon {
return Some(String::new());
}
None
}
#[must_use]
pub fn strip_verbatim_path_prefix_str(path: &str) -> Cow<'_, str> {
if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
Cow::Owned(format!(r"\\{rest}"))
} else if let Some(rest) = path.strip_prefix(r"\\?\") {
Cow::Borrowed(rest)
} else {
Cow::Borrowed(path)
}
}
#[must_use]
pub fn user_display_path(path: &Path) -> String {
strip_verbatim_path_prefix_str(&path.display().to_string()).replace('\\', "/")
}
#[must_use]
pub fn absolute_path_for_gitfile_stored_path(path: &Path) -> String {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
user_display_path(&canon)
}
#[must_use]
pub fn path_lexical_for_disk_compare(path: &Path) -> PathBuf {
let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let normalized = lexical_normalize_path_buf(abs);
#[cfg(windows)]
let normalized = PathBuf::from(
strip_verbatim_path_prefix_str(&normalized.display().to_string()).into_owned(),
);
normalized
}
fn lexical_normalize_path_buf(path: PathBuf) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
Component::Prefix(prefix) => out.push(prefix.as_os_str()),
Component::RootDir => out.push(c),
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
Component::Normal(_) => out.push(c),
}
}
out
}
#[must_use]
pub fn strip_worktree_prefix(path: &Path, work_tree: &Path) -> Option<String> {
let path_key = apply_macos_private_alias_for_compare(path_lexical_for_disk_compare(path));
let wt_key = path_for_disk_compare(work_tree);
let rel = path_key.strip_prefix(&wt_key).ok()?;
Some(path_to_git_slash(rel))
}
#[must_use]
pub fn path_to_git_slash(path: &Path) -> String {
path.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
#[must_use]
pub fn relative_path_for_display(from: &Path, to: &Path) -> Option<String> {
let from_cmp = path_for_disk_compare(from);
let to_cmp = apply_macos_private_alias_for_compare(path_lexical_for_disk_compare(to));
if from_cmp == to_cmp {
return Some(".".to_owned());
}
let from_parts: Vec<_> = from_cmp.components().collect();
let to_parts: Vec<_> = to_cmp.components().collect();
let common = from_parts
.iter()
.zip(to_parts.iter())
.take_while(|(a, b)| a == b)
.count();
let mut segments: Vec<String> = Vec::new();
for part in &from_parts[common..] {
if matches!(part, Component::Normal(_)) {
segments.push("..".to_owned());
}
}
for part in &to_parts[common..] {
match part {
Component::Normal(name) => segments.push(name.to_string_lossy().into_owned()),
Component::ParentDir => {
segments.pop();
}
Component::CurDir | Component::RootDir | Component::Prefix(_) => {}
}
}
if segments.is_empty() {
Some(".".to_owned())
} else {
Some(segments.join("/"))
}
}
#[must_use]
fn apply_macos_private_alias_for_compare(path: PathBuf) -> PathBuf {
#[cfg(target_os = "macos")]
{
if let Ok(stripped) = path.strip_prefix("/private") {
let without_private = PathBuf::from("/").join(stripped);
if without_private.exists() {
return without_private;
}
let mut probe = without_private.as_path();
while let Some(parent) = probe.parent() {
if parent == Path::new("") {
break;
}
if parent.exists() {
return without_private;
}
probe = parent;
}
}
}
path
}
#[must_use]
pub fn path_for_disk_compare(path: &Path) -> PathBuf {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
#[cfg(windows)]
let canon =
PathBuf::from(strip_verbatim_path_prefix_str(&canon.display().to_string()).into_owned());
apply_macos_private_alias_for_compare(canon)
}
#[must_use]
pub(crate) fn pathspec_traverses_symlink_dir(
work_tree: &Path,
rel_path: &str,
precompose_unicode: bool,
ignorecase: bool,
) -> bool {
if rel_path.is_empty()
|| has_glob_chars(rel_path)
|| rel_path.starts_with(":(")
|| rel_path.starts_with(':')
{
return false;
}
let directory_pathspec = rel_path.ends_with('/');
let components: Vec<&str> = rel_path.split('/').filter(|c| !c.is_empty()).collect();
if components.is_empty() {
return false;
}
let check_through = if directory_pathspec {
components.len()
} else if components.len() <= 1 {
return false;
} else {
components.len() - 1
};
let mut prefix = String::new();
for component in &components[..check_through] {
if !prefix.is_empty() {
prefix.push('/');
}
prefix.push_str(component);
let abs = if precompose_unicode {
resolve_worktree_path_for_staging(work_tree, &prefix, true).abs
} else {
worktree_path_for_index_entry(work_tree, &prefix, ignorecase)
};
let Ok(meta) = fs::symlink_metadata(&abs) else {
continue;
};
if meta.file_type().is_symlink() {
return true;
}
}
false
}
pub fn prefix_path_gently(prefix: &str, path: &str, work_tree: &Path) -> Option<String> {
if path.starts_with('/') {
let n = normalize_path_copy(path).ok()?;
abspath_part_inside_repo(&n, work_tree)
} else {
let concat = format!("{prefix}{path}");
normalize_path_copy(&concat).ok()
}
}
#[cfg(test)]
mod git_path_component_tests {
use super::*;
#[test]
#[cfg(unix)]
fn pathspec_traverses_symlink_dir_detects_intermediate_symlink() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("real")).expect("mkdir");
std::fs::write(root.join("real/f"), b"x").expect("write");
symlink("real", root.join("dl")).expect("symlink");
assert!(pathspec_traverses_symlink_dir(root, "dl/f", false, false));
assert!(!pathspec_traverses_symlink_dir(root, "dl", false, false));
assert!(!pathspec_traverses_symlink_dir(
root, "real/f", false, false
));
assert!(pathspec_traverses_symlink_dir(root, "dl/", false, false));
assert!(pathspec_traverses_symlink_dir(root, "dl/f/", false, false));
}
#[test]
fn cleanup_path_strips_leading_dot_slash() {
assert_eq!(cleanup_path("./foo"), "foo");
assert_eq!(cleanup_path(".//foo"), "foo");
assert_eq!(cleanup_path("foo"), "foo");
}
#[test]
fn cleanup_path_keeps_internal_double_slashes() {
assert_eq!(
cleanup_path("info//sparse-checkout"),
"info//sparse-checkout"
);
assert_eq!(cleanup_path("./info//grafts"), "info//grafts");
}
#[test]
fn git_path_component_drops_one_leading_slash_keeps_interior() {
assert_eq!(
git_path_relative_component("info//sparse-checkout"),
"info//sparse-checkout"
);
assert_eq!(git_path_relative_component("/info//grafts"), "info//grafts");
assert_eq!(git_path_relative_component("HEAD"), "HEAD");
}
#[test]
fn is_common_git_path_matches_git_common_list() {
use crate::refs::store::paths;
let common_cases = [
paths::logs_refs_rel().to_owned(),
format!("{}/", paths::logs_refs_rel()),
paths::logs_refs_child("bisec/foo"),
paths::logs_refs_child("bisec"),
paths::logs_refs_child("bisectfoo"),
"objects".to_owned(),
"objects/bar".to_owned(),
"info/exclude".to_owned(),
"info/grafts".to_owned(),
"remotes/bar".to_owned(),
"branches/bar".to_owned(),
paths::LOGS_REFS_HEADS_MAIN.to_owned(),
"refs/heads/main".to_owned(),
"hooks/me".to_owned(),
"config".to_owned(),
paths::packed_refs_rel().to_owned(),
"shallow".to_owned(),
"common".to_owned(),
"common/file".to_owned(),
];
for p in &common_cases {
assert!(is_common_git_path(p), "{p} should be common");
}
let worktree_cases = [
"index".to_owned(),
"index.lock".to_owned(),
"HEAD".to_owned(),
"logs/HEAD".to_owned(),
"logs/HEAD.lock".to_owned(),
paths::logs_refs_child("bisect/foo"),
"info/sparse-checkout".to_owned(),
"refs/bisect/foo".to_owned(),
];
for p in &worktree_cases {
assert!(!is_common_git_path(p), "{p} should be worktree-local");
}
}
#[test]
fn strip_verbatim_extended_path_prefix() {
assert_eq!(strip_verbatim_path_prefix_str(r"\\?\C:\tmp\p"), r"C:\tmp\p");
assert_eq!(
strip_verbatim_path_prefix_str(r"\\?\UNC\server\share\dir"),
r"\\server\share\dir"
);
assert_eq!(strip_verbatim_path_prefix_str(r"C:\tmp\p"), r"C:\tmp\p");
}
#[test]
fn user_display_path_uses_forward_slashes() {
assert_eq!(
user_display_path(Path::new(r"\\?\C:\tmp\p\.git")),
"C:/tmp/p/.git"
);
}
#[test]
fn absolute_path_for_gitfile_stored_path_strips_verbatim() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("linked");
std::fs::create_dir_all(&dir).expect("mkdir");
let stored = absolute_path_for_gitfile_stored_path(&dir);
assert!(!stored.contains(r"\\?\"));
assert!(!stored.contains("//?/"));
#[cfg(windows)]
{
assert!(stored.contains('/'));
assert_eq!(
absolute_path_for_gitfile_stored_path(Path::new(r"\\?\C:\tmp\t\src\.git")),
"C:/tmp/t/src/.git"
);
}
}
#[test]
fn relative_path_for_display_above_and_below_cwd() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = tmp.path().join("repo");
let deep = repo.join("d");
std::fs::create_dir_all(&deep).expect("mkdir");
std::fs::write(repo.join("a.txt"), b"root").expect("write");
std::fs::write(deep.join("a.txt"), b"nested").expect("write");
let cwd = deep.canonicalize().expect("canonicalize cwd");
let above = repo
.join("a.txt")
.canonicalize()
.expect("canonicalize above");
let below = deep
.join("a.txt")
.canonicalize()
.expect("canonicalize below");
assert_eq!(
relative_path_for_display(&cwd, &above).as_deref(),
Some("../a.txt")
);
assert_eq!(
relative_path_for_display(&cwd, &below).as_deref(),
Some("a.txt")
);
}
#[cfg(unix)]
#[test]
fn strip_worktree_prefix_preserves_symlink_spelling() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().expect("tempdir");
let repo = tmp.path().join("repo");
std::fs::create_dir_all(repo.join("d")).expect("mkdir");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&outside).expect("mkdir outside");
symlink(&outside, repo.join("d/link")).expect("symlink");
assert_eq!(
strip_worktree_prefix(&repo.join("d/link"), &repo).as_deref(),
Some("d/link")
);
}
#[test]
fn strip_worktree_prefix_for_nested_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = tmp.path().join("repo");
std::fs::create_dir_all(repo.join("d")).expect("mkdir");
let file = repo.join("d/a.txt");
std::fs::write(&file, b"x").expect("write");
assert_eq!(
strip_worktree_prefix(&file, &repo).as_deref(),
Some("d/a.txt")
);
}
#[cfg(target_os = "macos")]
#[test]
fn strip_worktree_prefix_macos_private_tmp_alias() {
let tmp = tempfile::tempdir().expect("tempdir");
let canon = tmp.path().canonicalize().expect("canonicalize tempdir");
let public = if let Ok(stripped) = canon.strip_prefix("/private") {
std::path::PathBuf::from("/").join(stripped)
} else {
canon.clone()
};
if public == canon {
return;
}
std::fs::create_dir_all(public.join("nested")).expect("mkdir");
let file_public = public.join("nested/file.txt");
std::fs::write(&file_public, b"x").expect("write");
assert_eq!(
strip_worktree_prefix(&file_public, &canon).as_deref(),
Some("nested/file.txt")
);
let missing_private = canon.join("nested/not-yet.txt");
assert_eq!(
strip_worktree_prefix(&missing_private, &canon).as_deref(),
Some("nested/not-yet.txt")
);
}
#[cfg(target_os = "macos")]
#[test]
fn relative_path_for_display_macos_private_tmp_alias() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo_canon = tmp.path().canonicalize().expect("canonicalize tempdir");
let repo_public = if let Ok(stripped) = repo_canon.strip_prefix("/private") {
std::path::PathBuf::from("/").join(stripped)
} else {
repo_canon.clone()
};
if repo_public == repo_canon {
return;
}
let sub = repo_public.join("sub");
std::fs::create_dir_all(&sub).expect("mkdir sub");
let file = sub.join("f.txt");
std::fs::write(&file, b"x").expect("write");
let cwd = sub.clone();
let target = repo_canon.join("sub/f.txt");
assert_eq!(
relative_path_for_display(&cwd, &target).as_deref(),
Some("f.txt")
);
let cwd_private = sub.canonicalize().expect("canonicalize sub");
assert_eq!(
relative_path_for_display(&cwd_private, &target).as_deref(),
Some("f.txt")
);
}
#[test]
fn relative_path_preserves_interior_double_slash_suffix() {
let mut sb = String::new();
let rel = relative_path("/repo/.git/info//sparse-checkout", "/repo", &mut sb);
assert_eq!(rel, Some(".git/info//sparse-checkout"));
}
}