Skip to main content

grit_lib/
git_path.rs

1//! Git-compatible path normalization (`normalize_path_copy`, `longest_ancestor_length`,
2//! `relative_path`) plus display and comparison helpers for repository paths.
3
4use std::borrow::Cow;
5use std::ffi::OsString;
6use std::fs;
7use std::path::{Component, Path, PathBuf};
8
9use crate::path_icase::worktree_path_for_index_entry;
10use crate::pathspec::has_glob_chars;
11use crate::unicode_normalization::resolve_worktree_path_for_staging;
12
13/// Errors returned by Git-compatible path helper routines.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum GitPathError {
16    /// Normalization would escape above the root.
17    EscapesRoot,
18    /// A relative URL cannot be resolved against the provided remote URL.
19    InvalidRelativeUrl,
20}
21
22#[inline]
23fn is_dir_sep(c: u8) -> bool {
24    c == b'/'
25}
26
27/// Purely textual path normalization matching Git's `normalize_path_copy`.
28/// Returns [`GitPathError::EscapesRoot`] when `..` would escape above the root
29/// (Git returns -1).
30pub fn normalize_path_copy(src: &str) -> Result<String, GitPathError> {
31    let is_abs = src.starts_with('/');
32    let raw_ends_dir = {
33        let stripped = src.trim_end_matches('/');
34        stripped.ends_with("/.")
35            || stripped.ends_with("/..")
36            || src.ends_with('/')
37            || src == "."
38            || src == ".."
39    };
40    let trailing_slash = raw_ends_dir && !src.is_empty();
41    let mut stack: Vec<String> = Vec::new();
42    let bytes = src.as_bytes();
43    let mut i = 0usize;
44    if is_abs {
45        i = 1;
46    }
47    while i < bytes.len() {
48        while i < bytes.len() && bytes[i] == b'/' {
49            i += 1;
50        }
51        if i >= bytes.len() {
52            break;
53        }
54        let start = i;
55        while i < bytes.len() && bytes[i] != b'/' {
56            i += 1;
57        }
58        let part = &src[start..i];
59        if part == "." {
60            continue;
61        }
62        if part == ".." {
63            if stack.pop().is_none() {
64                return Err(GitPathError::EscapesRoot);
65            }
66        } else {
67            stack.push(part.to_string());
68        }
69    }
70
71    let mut out = if is_abs {
72        if stack.is_empty() {
73            "/".to_string()
74        } else {
75            "/".to_string() + &stack.join("/")
76        }
77    } else if stack.is_empty() {
78        String::new()
79    } else {
80        stack.join("/")
81    };
82    if trailing_slash && !out.is_empty() && !out.ends_with('/') {
83        out.push('/');
84    }
85    Ok(out)
86}
87
88/// Git's `longest_ancestor_length` - normalizes `path` and each colon-separated prefix.
89pub fn longest_ancestor_length(path: &str, prefixes_colon_sep: &str) -> Result<i32, GitPathError> {
90    let path = normalize_path_copy(path)?;
91    if path == "/" {
92        return Ok(-1);
93    }
94    let mut max_len: i64 = -1;
95    for ceil_raw in prefixes_colon_sep.split(':') {
96        if ceil_raw.is_empty() {
97            continue;
98        }
99        let ceil = normalize_path_copy(ceil_raw)?;
100        let mut len = ceil.len();
101        if len > 0 && ceil.as_bytes()[len - 1] == b'/' {
102            len -= 1;
103        }
104        let p = path.as_bytes();
105        let c = ceil.as_bytes();
106        if len > p.len() || len > c.len() || p[..len] != c[..len] {
107            continue;
108        }
109        // Match git/path.c: need a '/' after the ceiling and another path component (not exact path).
110        if len == p.len() || p[len] != b'/' || p.get(len + 1).is_none() {
111            continue;
112        }
113        if len as i64 > max_len {
114            max_len = len as i64;
115        }
116    }
117    Ok(max_len as i32)
118}
119
120fn have_same_root(path1: &str, path2: &str) -> bool {
121    let abs1 = path1.starts_with('/');
122    let abs2 = path2.starts_with('/');
123    (abs1 && abs2) || (!abs1 && !abs2)
124}
125
126/// Git's `relative_path` from `path.c` (POSIX subset).
127pub fn relative_path<'a>(in_path: &'a str, prefix: &'a str, sb: &'a mut String) -> Option<&'a str> {
128    let in_len = in_path.len();
129    let prefix_len = prefix.len();
130    let mut in_off = 0usize;
131    let mut prefix_off = 0usize;
132    let mut i = 0usize;
133    let mut j = 0usize;
134
135    if in_len == 0 {
136        return Some("./");
137    }
138    if prefix_len == 0 {
139        return Some(in_path);
140    }
141
142    if !have_same_root(in_path, prefix) {
143        return Some(in_path);
144    }
145
146    let in_b = in_path.as_bytes();
147    let pre_b = prefix.as_bytes();
148
149    while i < prefix_len && j < in_len && pre_b[i] == in_b[j] {
150        if is_dir_sep(pre_b[i]) {
151            while i < prefix_len && is_dir_sep(pre_b[i]) {
152                i += 1;
153            }
154            while j < in_len && is_dir_sep(in_b[j]) {
155                j += 1;
156            }
157            prefix_off = i;
158            in_off = j;
159        } else {
160            i += 1;
161            j += 1;
162        }
163    }
164
165    if i >= prefix_len && prefix_off < prefix_len {
166        if j >= in_len {
167            in_off = in_len;
168        } else if is_dir_sep(in_b[j]) {
169            while j < in_len && is_dir_sep(in_b[j]) {
170                j += 1;
171            }
172            in_off = j;
173        } else {
174            i = prefix_off;
175        }
176    } else if j >= in_len && in_off < in_len && is_dir_sep(pre_b[i]) {
177        while i < prefix_len && is_dir_sep(pre_b[i]) {
178            i += 1;
179        }
180        in_off = in_len;
181    }
182
183    let in_suffix = &in_path[in_off..];
184    let in_suffix_len = in_suffix.len();
185
186    if i >= prefix_len {
187        if in_suffix_len == 0 {
188            return Some("./");
189        }
190        return Some(in_suffix);
191    }
192
193    sb.clear();
194    sb.reserve(in_suffix_len.saturating_add(prefix_len * 3));
195
196    while i < prefix_len {
197        if is_dir_sep(pre_b[i]) {
198            sb.push_str("../");
199            while i < prefix_len && is_dir_sep(pre_b[i]) {
200                i += 1;
201            }
202            continue;
203        }
204        i += 1;
205    }
206    if prefix_len > 0 && !is_dir_sep(pre_b[prefix_len - 1]) {
207        sb.push_str("../");
208    }
209    sb.push_str(in_suffix);
210
211    Some(sb.as_str())
212}
213
214/// Git's `cleanup_path` from `path.c`: strip a single leading `./` and any
215/// directory separators immediately following it. Internal consecutive slashes
216/// (e.g. `info//sparse-checkout`) are deliberately preserved so the result
217/// matches `git rev-parse --git-path` byte-for-byte.
218#[must_use]
219pub fn cleanup_path(path: &str) -> &str {
220    if let Some(rest) = path.strip_prefix("./") {
221        rest.trim_start_matches('/')
222    } else {
223        path
224    }
225}
226
227/// The relative portion of a `--git-path` argument, mirroring how Git builds the
228/// buffer in `repo_git_pathv`: the caller-supplied `fmt` string is appended to
229/// `<git_dir>/` verbatim and only [`cleanup_path`] runs over the whole buffer.
230/// In practice that means a single leading `/` (and a leading `./`) is dropped
231/// from the user-supplied component while internal `//` runs are kept intact.
232#[must_use]
233pub fn git_path_relative_component(path: &str) -> &str {
234    // Drop one leading slash (Git appends fmt right after "<git_dir>/", so a
235    // user "/foo" would otherwise become "<git_dir>//foo"); keep the rest as-is.
236    let trimmed = path.strip_prefix('/').unwrap_or(path);
237    cleanup_path(trimmed)
238}
239
240/// One entry in Git's `common_list` (`git/path.c`).
241struct CommonDir {
242    is_dir: bool,
243    is_common: bool,
244    path: &'static str,
245}
246
247/// Git's `common_list` table from `git/path.c`. Each entry classifies a path
248/// (or directory prefix) under the git dir as belonging to the common dir or to
249/// the per-worktree git dir. The order is irrelevant for classification because
250/// `trie_find` always selects the longest `/`-terminated matching prefix.
251const COMMON_LIST: &[CommonDir] = &[
252    CommonDir {
253        is_dir: true,
254        is_common: true,
255        path: "branches",
256    },
257    CommonDir {
258        is_dir: true,
259        is_common: true,
260        path: "common",
261    },
262    CommonDir {
263        is_dir: true,
264        is_common: true,
265        path: "hooks",
266    },
267    CommonDir {
268        is_dir: true,
269        is_common: true,
270        path: "info",
271    },
272    CommonDir {
273        is_dir: false,
274        is_common: false,
275        path: "info/sparse-checkout",
276    },
277    CommonDir {
278        is_dir: true,
279        is_common: true,
280        path: "logs",
281    },
282    CommonDir {
283        is_dir: false,
284        is_common: false,
285        path: "logs/HEAD",
286    },
287    CommonDir {
288        is_dir: true,
289        is_common: false,
290        path: crate::refs::store::paths::LOGS_REFS_BISECT,
291    },
292    CommonDir {
293        is_dir: true,
294        is_common: false,
295        path: crate::refs::store::paths::LOGS_REFS_REWRITTEN,
296    },
297    CommonDir {
298        is_dir: true,
299        is_common: false,
300        path: crate::refs::store::paths::LOGS_REFS_WORKTREE,
301    },
302    CommonDir {
303        is_dir: true,
304        is_common: true,
305        path: "lost-found",
306    },
307    CommonDir {
308        is_dir: true,
309        is_common: true,
310        path: "objects",
311    },
312    CommonDir {
313        is_dir: true,
314        is_common: true,
315        path: "refs",
316    },
317    CommonDir {
318        is_dir: true,
319        is_common: false,
320        path: "refs/bisect",
321    },
322    CommonDir {
323        is_dir: true,
324        is_common: false,
325        path: "refs/rewritten",
326    },
327    CommonDir {
328        is_dir: true,
329        is_common: false,
330        path: "refs/worktree",
331    },
332    CommonDir {
333        is_dir: true,
334        is_common: true,
335        path: "remotes",
336    },
337    CommonDir {
338        is_dir: true,
339        is_common: true,
340        path: "worktrees",
341    },
342    CommonDir {
343        is_dir: true,
344        is_common: true,
345        path: "rr-cache",
346    },
347    CommonDir {
348        is_dir: true,
349        is_common: true,
350        path: "svn",
351    },
352    CommonDir {
353        is_dir: false,
354        is_common: true,
355        path: "config",
356    },
357    CommonDir {
358        is_dir: false,
359        is_common: true,
360        path: "gc.pid",
361    },
362    CommonDir {
363        is_dir: false,
364        is_common: true,
365        path: crate::refs::store::paths::packed_refs_rel(),
366    },
367    CommonDir {
368        is_dir: false,
369        is_common: true,
370        path: "shallow",
371    },
372];
373
374/// Git's `check_common` (`git/path.c`): decide, for the matched `common_list`
375/// entry and the unmatched remainder of the key, whether the path is common.
376fn check_common(entry: &CommonDir, unmatched: &[u8]) -> Option<bool> {
377    let first = unmatched.first().copied();
378    if entry.is_dir && (first.is_none() || first == Some(b'/')) {
379        return Some(entry.is_common);
380    }
381    if !entry.is_dir && first.is_none() {
382        return Some(entry.is_common);
383    }
384    None
385}
386
387/// Compressed-trie lookup with the same behavior as Git's `trie_find`, specialized to
388/// `common_list` + `check_common`. Returns the longest `/`-or-`\0`-terminated
389/// `common_list` prefix's classification for `key`, or `None` if no prefix
390/// matches (treated as "not common" by callers).
391///
392/// Mirrors the C trie semantics including: partial normalization (consecutive
393/// slashes are skipped) and the fallback to a shorter `/`-terminated prefix when
394/// a longer node yields no verdict.
395fn trie_find_common(key: &[u8]) -> Option<bool> {
396    // The trie distinguishes nodes by their full path; longest match wins, but
397    // when the deepest matching node declines (`check_common` -> None) and we are
398    // at a `/` boundary, control falls back to the next-shorter prefix that has a
399    // value. We emulate this by scanning candidate prefixes from longest to
400    // shortest. A candidate is a `common_list` path P that is a prefix of the
401    // normalized key, terminated in the key by `\0` or `/`.
402    let norm = normalize_double_slashes(key);
403    // Collect matching entries, longest path first.
404    let mut matches: Vec<&CommonDir> = COMMON_LIST
405        .iter()
406        .filter(|e| key_has_prefix_node(&norm, e.path.as_bytes()))
407        .collect();
408    matches.sort_by_key(|a| std::cmp::Reverse(a.path.len()));
409    for entry in matches {
410        let plen = entry.path.len();
411        let unmatched = &norm[plen..];
412        if let Some(verdict) = check_common(entry, unmatched) {
413            return Some(verdict);
414        }
415        // No verdict at this node. The C trie only falls back to a shorter
416        // prefix; continue to the next (shorter) candidate.
417    }
418    None
419}
420
421/// Collapse runs of consecutive `/` to a single `/` (Git's partial path
422/// normalization inside `trie_find`). A trailing slash is preserved as a single
423/// slash so directory-prefix matching still sees the boundary.
424fn normalize_double_slashes(key: &[u8]) -> Vec<u8> {
425    let mut out = Vec::with_capacity(key.len());
426    let mut prev_slash = false;
427    for &b in key {
428        if b == b'/' {
429            if !prev_slash {
430                out.push(b);
431            }
432            prev_slash = true;
433        } else {
434            out.push(b);
435            prev_slash = false;
436        }
437    }
438    out
439}
440
441/// True when `node` is a prefix of `key` terminated by end-of-string or `/`.
442fn key_has_prefix_node(key: &[u8], node: &[u8]) -> bool {
443    if key.len() < node.len() || &key[..node.len()] != node {
444        return false;
445    }
446    matches!(key.get(node.len()), None | Some(b'/'))
447}
448
449/// True when the relative git-dir path `rel` belongs to the common (shared)
450/// directory, mirroring Git's `update_common_dir` decision (`git/path.c`).
451///
452/// `rel` is the component after the git dir (for example the reflog prefix from
453/// [`crate::refs::store::paths::logs_refs_rel`], `config`, or `HEAD`). Any trailing `.lock` suffix is ignored for the decision, exactly as
454/// `update_common_dir` strips `LOCK_SUFFIX` before consulting the trie.
455#[must_use]
456pub fn is_common_git_path(rel: &str) -> bool {
457    let stripped = rel.strip_suffix(".lock").unwrap_or(rel);
458    matches!(trie_find_common(stripped.as_bytes()), Some(true))
459}
460
461/// Git `setup.c` `abspath_part_inside_repo` (POSIX).
462///
463/// Strips the work tree from an absolute, normalized path, preserving symlink path
464/// components when they are still under the work tree as a string prefix.
465pub fn abspath_part_inside_repo(path: &str, work_tree: &Path) -> Option<String> {
466    let normalized = normalize_path_copy(path).ok()?;
467    if !normalized.starts_with('/') {
468        return None;
469    }
470    let wt_display = work_tree.to_string_lossy();
471    let wt_trim: &str = if wt_display == "/" {
472        "/"
473    } else {
474        wt_display.trim_end_matches('/')
475    };
476    let wt_len = wt_trim.len();
477    let p = normalized.as_str();
478    let len = p.len();
479
480    if wt_len <= len && p.starts_with(wt_trim) {
481        if len > wt_len && p.as_bytes()[wt_len] == b'/' {
482            return Some(p[wt_len + 1..].to_string());
483        }
484        if len == wt_len {
485            return Some(String::new());
486        }
487        if wt_len > 0 && wt_trim.as_bytes()[wt_len - 1] == b'/' {
488            return Some(p[wt_len..].trim_start_matches('/').to_string());
489        }
490    }
491
492    let wt_canon = path_for_disk_compare(work_tree);
493    let mut cum = String::new();
494    for seg in p.split('/').filter(|s| !s.is_empty()) {
495        cum.push('/');
496        cum.push_str(seg);
497        let rp = path_for_disk_compare(Path::new(&cum));
498        if rp == wt_canon {
499            if p.len() == cum.len() {
500                return Some(String::new());
501            }
502            if p.as_bytes().get(cum.len()) == Some(&b'/') {
503                return Some(p[cum.len() + 1..].to_string());
504            }
505        }
506    }
507    let full = path_for_disk_compare(Path::new(p));
508    if full == wt_canon {
509        return Some(String::new());
510    }
511    None
512}
513
514/// Git path variables copied into hook/filter subprocess environments.
515pub(crate) const SUBPROCESS_GIT_PATH_VARS: [&str; 5] = [
516    "GIT_DIR",
517    "GIT_COMMON_DIR",
518    "GIT_WORK_TREE",
519    "GIT_INDEX_FILE",
520    "GIT_OBJECT_DIRECTORY",
521];
522
523/// Strip Windows `\\?\` prefixes from path-valued `GIT_*` entries in a subprocess env block.
524pub(crate) fn strip_verbatim_prefix_from_subprocess_path_vars(set: &mut [(OsString, OsString)]) {
525    for (key, value) in set.iter_mut() {
526        if !SUBPROCESS_GIT_PATH_VARS.iter().any(|var| key == *var) {
527            continue;
528        }
529        let Some(s) = value.to_str() else {
530            continue;
531        };
532        let stripped = strip_verbatim_path_prefix_str(s);
533        if stripped.len() != s.len() {
534            *value = OsString::from(stripped.into_owned());
535        }
536    }
537}
538
539/// Strip Windows extended-length `\\?\` / `\\?\UNC\` prefixes for comparison and display.
540#[must_use]
541pub fn strip_verbatim_path_prefix_str(path: &str) -> Cow<'_, str> {
542    if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
543        Cow::Owned(format!(r"\\{rest}"))
544    } else if let Some(rest) = path.strip_prefix(r"\\?\") {
545        Cow::Borrowed(rest)
546    } else {
547        Cow::Borrowed(path)
548    }
549}
550
551/// `path` without a Windows `\\?\` / `\\?\UNC\` prefix, for paths handed to child processes.
552///
553/// [`Path::canonicalize`] returns verbatim paths on Windows, and MSYS tools such as Git for
554/// Windows' `sh` cannot open them. Other platforms return `path` unchanged.
555#[must_use]
556pub(crate) fn path_without_verbatim_prefix(path: &Path) -> Cow<'_, Path> {
557    #[cfg(windows)]
558    if let Some(s) = path.to_str() {
559        let stripped = strip_verbatim_path_prefix_str(s);
560        if stripped.len() != s.len() {
561            return Cow::Owned(PathBuf::from(stripped.into_owned()));
562        }
563    }
564    Cow::Borrowed(path)
565}
566
567/// Longest path, in UTF-16 units, that Win32 file APIs accept without the `\\?\` prefix
568/// (`MAX_PATH` less the terminating NUL).
569#[cfg(any(windows, test))]
570const WIN32_MAX_PATH_UNITS: usize = 259;
571
572/// Win32 spelling of a verbatim drive (`\\?\C:\…`) or UNC (`\\?\UNC\server\share\…`) path.
573///
574/// Returns `None` when `path` is not one of those forms, or when dropping the prefix would
575/// change which file the path names or push it past `MAX_PATH`: components Win32 rewrites or
576/// rejects (empty, `.`, `..`, a trailing `.` or space, `/`, `:`, wildcards, control
577/// characters), reserved device names such as `NUL` or `COM1.txt`, and over-long paths all keep
578/// the prefix. Volume GUID (`\\?\Volume{…}`) and device (`\\.\`) paths are never rewritten.
579#[cfg(any(windows, test))]
580fn simplify_verbatim_str(path: &str) -> Option<String> {
581    let (simple, components) = if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
582        let mut parts = rest.splitn(3, '\\');
583        let (server, share) = (parts.next()?, parts.next()?);
584        if server.is_empty() || share.is_empty() {
585            return None;
586        }
587        (format!(r"\\{rest}"), rest)
588    } else {
589        let rest = path.strip_prefix(r"\\?\")?;
590        let drive = rest.as_bytes();
591        if drive.len() < 3 || !drive[0].is_ascii_alphabetic() || drive[1..3] != *b":\\" {
592            return None;
593        }
594        (rest.to_owned(), &rest[3..])
595    };
596    let round_trips =
597        components.is_empty() || components.split('\\').all(win32_component_round_trips);
598    (round_trips && simple.encode_utf16().count() <= WIN32_MAX_PATH_UNITS).then_some(simple)
599}
600
601/// Whether Win32 path parsing leaves `component` unchanged and names a regular file.
602#[cfg(any(windows, test))]
603pub(crate) fn win32_component_round_trips(component: &str) -> bool {
604    const RESERVED: [&str; 6] = ["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"];
605    if component.is_empty()
606        || component.ends_with(['.', ' '])
607        || component.contains(['/', ':', '<', '>', '"', '|', '?', '*'])
608        || component.chars().any(char::is_control)
609    {
610        return false;
611    }
612    let stem = component
613        .split('.')
614        .next()
615        .unwrap_or(component)
616        .trim_end_matches(' ');
617    if RESERVED.iter().any(|name| stem.eq_ignore_ascii_case(name)) {
618        return false;
619    }
620    let numbered_device = ["COM", "LPT"].iter().any(|device| {
621        stem.get(..3)
622            .is_some_and(|head| head.eq_ignore_ascii_case(device))
623            && matches!(
624                &stem[3..],
625                "1" | "2"
626                    | "3"
627                    | "4"
628                    | "5"
629                    | "6"
630                    | "7"
631                    | "8"
632                    | "9"
633                    | "\u{b9}"
634                    | "\u{b2}"
635                    | "\u{b3}"
636            )
637    });
638    !numbered_device
639}
640
641/// Whether every `/`-separated component of a Git storage path is safe as a Win32 file name.
642#[cfg(any(windows, test))]
643pub(crate) fn win32_storage_path_components_round_trip(path: &str) -> bool {
644    path.split('/').all(win32_component_round_trips)
645}
646
647/// `path` in its plain Win32 spelling when it is a verbatim (`\\?\`) path that names the same
648/// file without the prefix; every other path, and every path off Windows, is returned unchanged.
649///
650/// [`std::fs::canonicalize`] always returns verbatim paths on Windows. Paths stored on a
651/// [`crate::repo::Repository`] go through this so they compare equal to, and display like, the
652/// paths callers passed in. Over-long and otherwise unrepresentable paths keep the prefix so
653/// they still open.
654#[must_use]
655pub(crate) fn simplify_verbatim_path(path: PathBuf) -> PathBuf {
656    #[cfg(windows)]
657    if let Some(simple) = path.to_str().and_then(simplify_verbatim_str) {
658        return PathBuf::from(simple);
659    }
660    path
661}
662
663/// [`Path::canonicalize`] followed by [`simplify_verbatim_path`].
664///
665/// Use this instead of [`Path::canonicalize`] everywhere in the crate, so canonical paths
666/// stored on a repository handle and canonical paths computed later compare equal.
667pub(crate) trait CanonicalizeExt {
668    /// Canonical absolute form of `self`, without a needless Windows `\\?\` prefix.
669    ///
670    /// # Errors
671    ///
672    /// Returns the [`std::io::Error`] from [`std::fs::canonicalize`] (for example when the
673    /// path does not exist).
674    fn canonicalize_simplified(&self) -> std::io::Result<PathBuf>;
675}
676
677impl CanonicalizeExt for Path {
678    #[allow(clippy::disallowed_methods)]
679    fn canonicalize_simplified(&self) -> std::io::Result<PathBuf> {
680        fs::canonicalize(self).map(simplify_verbatim_path)
681    }
682}
683
684/// User-facing path: forward slashes, no verbatim prefix (Git-style init/status paths).
685#[must_use]
686pub fn user_display_path(path: &Path) -> String {
687    strip_verbatim_path_prefix_str(&path.display().to_string()).replace('\\', "/")
688}
689
690/// User-facing path for error messages (alias of [`user_display_path`]).
691#[must_use]
692pub fn path_for_error_message(path: &Path) -> String {
693    user_display_path(path)
694}
695
696/// Absolute path for Git worktree gitfiles (linked `.git` `gitdir:` lines and `gitdir` files).
697///
698/// Resolves symlinks via [`std::fs::canonicalize`] when possible, strips Windows `\\?\`
699/// extended-length prefixes, and uses forward slashes so system Git can open linked worktrees.
700#[must_use]
701pub fn absolute_path_for_gitfile_stored_path(path: &Path) -> String {
702    let canon = path
703        .canonicalize_simplified()
704        .unwrap_or_else(|_| path.to_path_buf());
705    user_display_path(&canon)
706}
707
708/// Lexical absolute path for comparison (does not follow symlinks).
709///
710/// Strips Windows verbatim prefixes when present. Used for repository entry paths so
711/// symlinks keep their repository spelling in status and pathspec resolution.
712#[must_use]
713pub fn path_lexical_for_disk_compare(path: &Path) -> PathBuf {
714    let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
715    let normalized = lexical_normalize_path_buf(abs);
716    #[cfg(windows)]
717    let normalized = PathBuf::from(
718        strip_verbatim_path_prefix_str(&normalized.display().to_string()).into_owned(),
719    );
720    normalized
721}
722
723fn lexical_normalize_path_buf(path: PathBuf) -> PathBuf {
724    let mut out = PathBuf::new();
725    for c in path.components() {
726        match c {
727            Component::Prefix(prefix) => out.push(prefix.as_os_str()),
728            Component::RootDir => out.push(c),
729            Component::CurDir => {}
730            Component::ParentDir => {
731                out.pop();
732            }
733            Component::Normal(_) => out.push(c),
734        }
735    }
736    out
737}
738
739/// Repository-relative path with forward slashes when `path` lies under `work_tree`.
740#[must_use]
741pub fn strip_worktree_prefix(path: &Path, work_tree: &Path) -> Option<String> {
742    let path_key = apply_macos_private_alias_for_compare(path_lexical_for_disk_compare(path));
743    let wt_key = path_for_disk_compare(work_tree);
744    let rel = path_key.strip_prefix(&wt_key).ok()?;
745    Some(path_to_git_slash(rel))
746}
747
748/// Convert a filesystem path to Git's slash-separated repo-relative form.
749#[must_use]
750pub fn path_to_git_slash(path: &Path) -> String {
751    path.components()
752        .filter_map(|c| match c {
753            Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
754            _ => None,
755        })
756        .collect::<Vec<_>>()
757        .join("/")
758}
759
760/// Path of `to` relative to `from`, for display (forward slashes).
761///
762/// Uses normalized paths (including Windows verbatim-prefix stripping) and walks up from
763/// the common ancestor with `..` when the target is not under `from`.
764#[must_use]
765pub fn relative_path_for_display(from: &Path, to: &Path) -> Option<String> {
766    let from_cmp = path_for_disk_compare(from);
767    let to_cmp = apply_macos_private_alias_for_compare(path_lexical_for_disk_compare(to));
768    if from_cmp == to_cmp {
769        return Some(".".to_owned());
770    }
771
772    let from_parts: Vec<_> = from_cmp.components().collect();
773    let to_parts: Vec<_> = to_cmp.components().collect();
774    let common = from_parts
775        .iter()
776        .zip(to_parts.iter())
777        .take_while(|(a, b)| a == b)
778        .count();
779
780    let mut segments: Vec<String> = Vec::new();
781    for part in &from_parts[common..] {
782        if matches!(part, Component::Normal(_)) {
783            segments.push("..".to_owned());
784        }
785    }
786    for part in &to_parts[common..] {
787        match part {
788            Component::Normal(name) => segments.push(name.to_string_lossy().into_owned()),
789            Component::ParentDir => {
790                segments.pop();
791            }
792            Component::CurDir | Component::RootDir | Component::Prefix(_) => {}
793        }
794    }
795
796    if segments.is_empty() {
797        Some(".".to_owned())
798    } else {
799        Some(segments.join("/"))
800    }
801}
802
803/// Normalize macOS `/private` spellings for path comparison keys.
804///
805/// `/tmp` and `/private/tmp` refer to the same directory; use the spelling without
806/// `/private` when that path exists, or when an ancestor exists at the public spelling
807/// (so not-yet-created paths still match a canonical work tree).
808#[must_use]
809fn apply_macos_private_alias_for_compare(path: PathBuf) -> PathBuf {
810    #[cfg(target_os = "macos")]
811    {
812        if let Ok(stripped) = path.strip_prefix("/private") {
813            let without_private = PathBuf::from("/").join(stripped);
814            if without_private.exists() {
815                return without_private;
816            }
817            let mut probe = without_private.as_path();
818            while let Some(parent) = probe.parent() {
819                if parent == Path::new("") {
820                    break;
821                }
822                if parent.exists() {
823                    return without_private;
824                }
825                probe = parent;
826            }
827        }
828    }
829    path
830}
831
832/// Canonicalize a path for on-disk comparison (macOS `/private` aliasing).
833///
834/// On macOS, `/tmp` and `/private/tmp` refer to the same directory; Git stores and
835/// accepts both spellings when matching paths against `core.worktree`.
836///
837/// On Windows, `canonicalize` may return `\\?\`-prefixed paths; those are stripped so
838/// comparisons match non-verbatim spellings (`C:\…`).
839#[must_use]
840pub fn path_for_disk_compare(path: &Path) -> PathBuf {
841    let canon = path
842        .canonicalize_simplified()
843        .unwrap_or_else(|_| path.to_path_buf());
844    #[cfg(windows)]
845    let canon =
846        PathBuf::from(strip_verbatim_path_prefix_str(&canon.display().to_string()).into_owned());
847    apply_macos_private_alias_for_compare(canon)
848}
849
850/// Returns whether `rel_path` has a symlink among its directory components.
851///
852/// Git rejects pathspecs that traverse through a symlinked directory (for example
853/// `dl/f` when `dl` is a symlink). Staging the symlink blob at `dl` itself is allowed.
854#[must_use]
855pub(crate) fn pathspec_traverses_symlink_dir(
856    work_tree: &Path,
857    rel_path: &str,
858    precompose_unicode: bool,
859    ignorecase: bool,
860) -> bool {
861    if rel_path.is_empty()
862        || has_glob_chars(rel_path)
863        || rel_path.starts_with(":(")
864        || rel_path.starts_with(':')
865    {
866        return false;
867    }
868    let directory_pathspec = rel_path.ends_with('/');
869    let components: Vec<&str> = rel_path.split('/').filter(|c| !c.is_empty()).collect();
870    if components.is_empty() {
871        return false;
872    }
873    let check_through = if directory_pathspec {
874        components.len()
875    } else if components.len() <= 1 {
876        return false;
877    } else {
878        components.len() - 1
879    };
880    let mut prefix = String::new();
881    for component in &components[..check_through] {
882        if !prefix.is_empty() {
883            prefix.push('/');
884        }
885        prefix.push_str(component);
886        let abs = if precompose_unicode {
887            resolve_worktree_path_for_staging(work_tree, &prefix, true).abs
888        } else {
889            worktree_path_for_index_entry(work_tree, &prefix, ignorecase)
890        };
891        let Ok(meta) = fs::symlink_metadata(&abs) else {
892            continue;
893        };
894        if meta.file_type().is_symlink() {
895            return true;
896        }
897    }
898    false
899}
900
901/// Git `setup.c` `prefix_path_gently` (POSIX).
902pub fn prefix_path_gently(prefix: &str, path: &str, work_tree: &Path) -> Option<String> {
903    if path.starts_with('/') {
904        let n = normalize_path_copy(path).ok()?;
905        abspath_part_inside_repo(&n, work_tree)
906    } else {
907        let concat = format!("{prefix}{path}");
908        normalize_path_copy(&concat).ok()
909    }
910}
911
912#[cfg(test)]
913mod git_path_component_tests {
914    use super::*;
915
916    #[test]
917    #[cfg(unix)]
918    fn pathspec_traverses_symlink_dir_detects_intermediate_symlink() {
919        use std::os::unix::fs::symlink;
920
921        let tmp = tempfile::TempDir::new().expect("tempdir");
922        let root = tmp.path();
923        std::fs::create_dir_all(root.join("real")).expect("mkdir");
924        std::fs::write(root.join("real/f"), b"x").expect("write");
925        symlink("real", root.join("dl")).expect("symlink");
926        assert!(pathspec_traverses_symlink_dir(root, "dl/f", false, false));
927        assert!(!pathspec_traverses_symlink_dir(root, "dl", false, false));
928        assert!(!pathspec_traverses_symlink_dir(
929            root, "real/f", false, false
930        ));
931        assert!(pathspec_traverses_symlink_dir(root, "dl/", false, false));
932        assert!(pathspec_traverses_symlink_dir(root, "dl/f/", false, false));
933    }
934
935    #[test]
936    fn cleanup_path_strips_leading_dot_slash() {
937        assert_eq!(cleanup_path("./foo"), "foo");
938        assert_eq!(cleanup_path(".//foo"), "foo");
939        assert_eq!(cleanup_path("foo"), "foo");
940    }
941
942    #[test]
943    fn cleanup_path_keeps_internal_double_slashes() {
944        // Git's cleanup_path never collapses interior consecutive slashes.
945        assert_eq!(
946            cleanup_path("info//sparse-checkout"),
947            "info//sparse-checkout"
948        );
949        assert_eq!(cleanup_path("./info//grafts"), "info//grafts");
950    }
951
952    #[test]
953    fn git_path_component_drops_one_leading_slash_keeps_interior() {
954        assert_eq!(
955            git_path_relative_component("info//sparse-checkout"),
956            "info//sparse-checkout"
957        );
958        assert_eq!(git_path_relative_component("/info//grafts"), "info//grafts");
959        assert_eq!(git_path_relative_component("HEAD"), "HEAD");
960    }
961
962    #[test]
963    fn is_common_git_path_matches_git_common_list() {
964        use crate::refs::store::paths;
965        let common_cases = [
966            paths::logs_refs_rel().to_owned(),
967            format!("{}/", paths::logs_refs_rel()),
968            paths::logs_refs_child("bisec/foo"),
969            paths::logs_refs_child("bisec"),
970            paths::logs_refs_child("bisectfoo"),
971            "objects".to_owned(),
972            "objects/bar".to_owned(),
973            "info/exclude".to_owned(),
974            "info/grafts".to_owned(),
975            "remotes/bar".to_owned(),
976            "branches/bar".to_owned(),
977            paths::LOGS_REFS_HEADS_MAIN.to_owned(),
978            "refs/heads/main".to_owned(),
979            "hooks/me".to_owned(),
980            "config".to_owned(),
981            paths::packed_refs_rel().to_owned(),
982            "shallow".to_owned(),
983            "common".to_owned(),
984            "common/file".to_owned(),
985        ];
986        // Common (resolved against the common dir) — t0060 cases.
987        for p in &common_cases {
988            assert!(is_common_git_path(p), "{p} should be common");
989        }
990        let worktree_cases = [
991            "index".to_owned(),
992            "index.lock".to_owned(),
993            "HEAD".to_owned(),
994            "logs/HEAD".to_owned(),
995            "logs/HEAD.lock".to_owned(),
996            paths::logs_refs_child("bisect/foo"),
997            "info/sparse-checkout".to_owned(),
998            "refs/bisect/foo".to_owned(),
999        ];
1000        // Per-worktree (resolved against the git dir) — t0060 cases.
1001        for p in &worktree_cases {
1002            assert!(!is_common_git_path(p), "{p} should be worktree-local");
1003        }
1004    }
1005
1006    #[test]
1007    fn strip_verbatim_extended_path_prefix() {
1008        assert_eq!(strip_verbatim_path_prefix_str(r"\\?\C:\tmp\p"), r"C:\tmp\p");
1009        assert_eq!(
1010            strip_verbatim_path_prefix_str(r"\\?\UNC\server\share\dir"),
1011            r"\\server\share\dir"
1012        );
1013        assert_eq!(strip_verbatim_path_prefix_str(r"C:\tmp\p"), r"C:\tmp\p");
1014    }
1015
1016    #[test]
1017    fn simplify_verbatim_str_rewrites_only_round_tripping_paths() {
1018        let long_tail = "d".repeat(WIN32_MAX_PATH_UNITS - r"C:\".len());
1019        let too_long_tail = format!("{long_tail}x");
1020        let long_drive = format!(r"\\?\C:\{long_tail}");
1021        let too_long_drive = format!(r"\\?\C:\{too_long_tail}");
1022        let cases: &[(&str, Option<&str>)] = &[
1023            (r"\\?\C:\Users\me\proj", Some(r"C:\Users\me\proj")),
1024            (r"\\?\c:\proj\.git", Some(r"c:\proj\.git")),
1025            (r"\\?\C:\", Some(r"C:\")),
1026            (r"\\?\C:\a b\file.txt", Some(r"C:\a b\file.txt")),
1027            ("\\\\?\\C:\\caf\u{e9}\\x", Some("C:\\caf\u{e9}\\x")),
1028            (r"\\?\C:\console\nulled", Some(r"C:\console\nulled")),
1029            (r"\\?\C:\COM10\LPT0", Some(r"C:\COM10\LPT0")),
1030            (r"\\?\UNC\server\share\dir", Some(r"\\server\share\dir")),
1031            (r"\\?\UNC\server\share", Some(r"\\server\share")),
1032            (long_drive.as_str(), Some(&long_drive[4..])),
1033            (too_long_drive.as_str(), None),
1034            (r"\\?\C:\proj\dir.", None),
1035            (r"\\?\C:\proj\dir ", None),
1036            (r"\\?\C:\proj\.\x", None),
1037            (r"\\?\C:\proj\..\x", None),
1038            (r"\\?\C:\proj\\x", None),
1039            (r"\\?\C:\proj\a/b", None),
1040            (r"\\?\C:\proj\a:b", None),
1041            (r"\\?\C:\proj\a*b", None),
1042            (r"\\?\C:\proj\NUL", None),
1043            (r"\\?\C:\proj\nul.txt", None),
1044            (r"\\?\C:\proj\Com1", None),
1045            (r"\\?\C:\proj\lpt9.log", None),
1046            ("\\\\?\\C:\\proj\\COM\u{b9}", None),
1047            (r"\\?\C:\proj\CONOUT$", None),
1048            (r"\\?\C:", None),
1049            (r"\\?\C:proj", None),
1050            (r"\\?\1:\proj", None),
1051            (r"\\?\UNC\server", None),
1052            (r"\\?\UNC\\share", None),
1053            (r"\\?\UNC\server\share\x.", None),
1054            (
1055                r"\\?\Volume{0a1b2c3d-0000-0000-0000-100000000000}\dir",
1056                None,
1057            ),
1058            (r"\\.\C:\proj", None),
1059            (r"\\.\PhysicalDrive0", None),
1060            (r"\??\C:\proj", None),
1061            (r"C:\proj", None),
1062            (r"\\server\share", None),
1063            ("/home/me/proj", None),
1064        ];
1065        for (input, expected) in cases {
1066            assert_eq!(
1067                simplify_verbatim_str(input).as_deref(),
1068                *expected,
1069                "simplify {input:?}"
1070            );
1071        }
1072    }
1073
1074    #[test]
1075    fn simplify_verbatim_path_keeps_non_windows_paths() {
1076        let unix = PathBuf::from("/home/me/proj");
1077        assert_eq!(simplify_verbatim_path(unix.clone()), unix);
1078        let verbatim = PathBuf::from(r"\\?\C:\proj");
1079        let expected = if cfg!(windows) {
1080            PathBuf::from(r"C:\proj")
1081        } else {
1082            verbatim.clone()
1083        };
1084        assert_eq!(simplify_verbatim_path(verbatim), expected);
1085    }
1086
1087    #[test]
1088    fn canonicalize_simplified_matches_canonicalize_without_prefix() {
1089        let tmp = tempfile::tempdir().expect("tempdir");
1090        let dir = tmp.path().join("a").join("..").join("b");
1091        std::fs::create_dir_all(tmp.path().join("a")).expect("mkdir a");
1092        std::fs::create_dir_all(tmp.path().join("b")).expect("mkdir b");
1093        let simplified = dir.canonicalize_simplified().expect("canonicalize");
1094        let raw = std::fs::canonicalize(&dir).expect("canonicalize");
1095        assert_eq!(simplified, simplify_verbatim_path(raw));
1096        assert!(!simplified.to_string_lossy().starts_with(r"\\?\"));
1097        assert!(simplified.is_dir());
1098        assert!(Path::new("does-not-exist-here")
1099            .canonicalize_simplified()
1100            .is_err());
1101    }
1102
1103    #[test]
1104    fn repository_paths_are_simplified_canonical_paths() {
1105        use crate::config::environment::RepositoryOptions;
1106        use crate::ref_storage::RefStorageFormat;
1107        use crate::repo::{init_repository, Repository};
1108
1109        let tmp = tempfile::tempdir().expect("tempdir");
1110        let work = tmp.path().join("hw");
1111        init_repository(&work, false, "main", None, RefStorageFormat::Files).expect("init");
1112        std::fs::create_dir_all(work.join("sub")).expect("mkdir sub");
1113        let expected = work.canonicalize_simplified().expect("canonicalize");
1114
1115        for start in [work.clone(), work.join("sub"), work.join("sub").join("..")] {
1116            let repo = Repository::discover(Some(start.as_path()), &RepositoryOptions::empty())
1117                .expect("discover");
1118            let wt = repo.work_tree().expect("work tree");
1119            assert!(!wt.to_string_lossy().starts_with(r"\\?\"), "{wt:?}");
1120            assert_eq!(wt, expected.as_path());
1121            assert_eq!(repo.git_dir(), expected.join(".git"));
1122            assert_eq!(repo.common_git_dir(), repo.git_dir());
1123            assert!(repo.git_dir().join("config").is_file());
1124            repo.config().expect("config through stored git dir");
1125        }
1126
1127        let opened = Repository::open(
1128            &work.join(".git"),
1129            &RepositoryOptions::empty().with_work_tree(&work),
1130        )
1131        .expect("open");
1132        assert_eq!(opened.work_tree(), Some(expected.as_path()));
1133        assert_eq!(opened.git_dir(), expected.join(".git"));
1134    }
1135
1136    #[test]
1137    fn separate_git_dir_gitfile_is_plain_absolute_path() {
1138        use crate::objects::HashAlgo;
1139        use crate::ref_storage::RefStorageFormat;
1140        use crate::repo::init_repository_separate_git_dir;
1141
1142        let tmp = tempfile::tempdir().expect("tempdir");
1143        let work = tmp.path().join("wt");
1144        let git_dir = tmp.path().join("store.git");
1145        let repo = init_repository_separate_git_dir(
1146            &work,
1147            &git_dir,
1148            "main",
1149            None,
1150            RefStorageFormat::Files,
1151            HashAlgo::Sha1,
1152            &crate::environment::Environment::empty(),
1153        )
1154        .expect("init separate git dir");
1155        let gitfile = std::fs::read_to_string(work.join(".git")).expect("read gitfile");
1156        let stored = gitfile
1157            .strip_prefix("gitdir: ")
1158            .and_then(|rest| rest.strip_suffix('\n'))
1159            .expect("gitdir line");
1160        assert!(!stored.contains("?"), "{stored}");
1161        assert_eq!(stored, absolute_path_for_gitfile_stored_path(&git_dir));
1162        assert_eq!(
1163            repo.git_dir(),
1164            git_dir.canonicalize_simplified().expect("canonicalize")
1165        );
1166
1167        let out = std::process::Command::new("git")
1168            .args(["rev-parse", "--absolute-git-dir"])
1169            .current_dir(&work)
1170            .env("GIT_CEILING_DIRECTORIES", tmp.path())
1171            .output();
1172        let Ok(out) = out else {
1173            return;
1174        };
1175        assert!(out.status.success(), "git rev-parse failed: {out:?}");
1176        let git_view = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
1177        assert_eq!(
1178            git_view
1179                .canonicalize_simplified()
1180                .expect("canonicalize git view"),
1181            repo.git_dir()
1182        );
1183    }
1184
1185    #[test]
1186    fn user_display_path_uses_forward_slashes() {
1187        assert_eq!(
1188            user_display_path(Path::new(r"\\?\C:\tmp\p\.git")),
1189            "C:/tmp/p/.git"
1190        );
1191    }
1192
1193    #[test]
1194    fn path_for_error_message_strips_verbatim_prefix() {
1195        let msg = path_for_error_message(Path::new(r"\\?\C:\Users\tmp\r"));
1196        assert!(!msg.contains(r"\\?\"));
1197        assert_eq!(msg, "C:/Users/tmp/r");
1198    }
1199
1200    #[test]
1201    fn absolute_path_for_gitfile_stored_path_strips_verbatim() {
1202        let tmp = tempfile::tempdir().expect("tempdir");
1203        let dir = tmp.path().join("linked");
1204        std::fs::create_dir_all(&dir).expect("mkdir");
1205        let stored = absolute_path_for_gitfile_stored_path(&dir);
1206        assert!(!stored.contains(r"\\?\"));
1207        assert!(!stored.contains("//?/"));
1208        #[cfg(windows)]
1209        {
1210            assert!(stored.contains('/'));
1211            assert_eq!(
1212                absolute_path_for_gitfile_stored_path(Path::new(r"\\?\C:\tmp\t\src\.git")),
1213                "C:/tmp/t/src/.git"
1214            );
1215        }
1216    }
1217
1218    #[test]
1219    fn relative_path_for_display_above_and_below_cwd() {
1220        let tmp = tempfile::tempdir().expect("tempdir");
1221        let repo = tmp.path().join("repo");
1222        let deep = repo.join("d");
1223        std::fs::create_dir_all(&deep).expect("mkdir");
1224        std::fs::write(repo.join("a.txt"), b"root").expect("write");
1225        std::fs::write(deep.join("a.txt"), b"nested").expect("write");
1226
1227        let cwd = deep.canonicalize_simplified().expect("canonicalize cwd");
1228        let above = repo
1229            .join("a.txt")
1230            .canonicalize_simplified()
1231            .expect("canonicalize above");
1232        let below = deep
1233            .join("a.txt")
1234            .canonicalize_simplified()
1235            .expect("canonicalize below");
1236
1237        assert_eq!(
1238            relative_path_for_display(&cwd, &above).as_deref(),
1239            Some("../a.txt")
1240        );
1241        assert_eq!(
1242            relative_path_for_display(&cwd, &below).as_deref(),
1243            Some("a.txt")
1244        );
1245    }
1246
1247    #[cfg(unix)]
1248    #[test]
1249    fn strip_worktree_prefix_preserves_symlink_spelling() {
1250        use std::os::unix::fs::symlink;
1251
1252        let tmp = tempfile::tempdir().expect("tempdir");
1253        let repo = tmp.path().join("repo");
1254        std::fs::create_dir_all(repo.join("d")).expect("mkdir");
1255        let outside = tmp.path().join("outside");
1256        std::fs::create_dir_all(&outside).expect("mkdir outside");
1257        symlink(&outside, repo.join("d/link")).expect("symlink");
1258        assert_eq!(
1259            strip_worktree_prefix(&repo.join("d/link"), &repo).as_deref(),
1260            Some("d/link")
1261        );
1262    }
1263
1264    #[test]
1265    fn strip_worktree_prefix_for_nested_file() {
1266        let tmp = tempfile::tempdir().expect("tempdir");
1267        let repo = tmp.path().join("repo");
1268        std::fs::create_dir_all(repo.join("d")).expect("mkdir");
1269        let file = repo.join("d/a.txt");
1270        std::fs::write(&file, b"x").expect("write");
1271        assert_eq!(
1272            strip_worktree_prefix(&file, &repo).as_deref(),
1273            Some("d/a.txt")
1274        );
1275    }
1276
1277    #[cfg(target_os = "macos")]
1278    #[test]
1279    fn strip_worktree_prefix_macos_private_tmp_alias() {
1280        let tmp = tempfile::tempdir().expect("tempdir");
1281        let canon = tmp
1282            .path()
1283            .canonicalize_simplified()
1284            .expect("canonicalize tempdir");
1285        let public = if let Ok(stripped) = canon.strip_prefix("/private") {
1286            std::path::PathBuf::from("/").join(stripped)
1287        } else {
1288            canon.clone()
1289        };
1290        if public == canon {
1291            return;
1292        }
1293        std::fs::create_dir_all(public.join("nested")).expect("mkdir");
1294        let file_public = public.join("nested/file.txt");
1295        std::fs::write(&file_public, b"x").expect("write");
1296        assert_eq!(
1297            strip_worktree_prefix(&file_public, &canon).as_deref(),
1298            Some("nested/file.txt")
1299        );
1300
1301        let missing_private = canon.join("nested/not-yet.txt");
1302        assert_eq!(
1303            strip_worktree_prefix(&missing_private, &canon).as_deref(),
1304            Some("nested/not-yet.txt")
1305        );
1306    }
1307
1308    #[cfg(target_os = "macos")]
1309    #[test]
1310    fn relative_path_for_display_macos_private_tmp_alias() {
1311        let tmp = tempfile::tempdir().expect("tempdir");
1312        let repo_canon = tmp
1313            .path()
1314            .canonicalize_simplified()
1315            .expect("canonicalize tempdir");
1316        let repo_public = if let Ok(stripped) = repo_canon.strip_prefix("/private") {
1317            std::path::PathBuf::from("/").join(stripped)
1318        } else {
1319            repo_canon.clone()
1320        };
1321        if repo_public == repo_canon {
1322            return;
1323        }
1324        let sub = repo_public.join("sub");
1325        std::fs::create_dir_all(&sub).expect("mkdir sub");
1326        let file = sub.join("f.txt");
1327        std::fs::write(&file, b"x").expect("write");
1328
1329        // cwd at public /tmp/.../sub, target under repo (private spelling in path buffer).
1330        let cwd = sub.clone();
1331        let target = repo_canon.join("sub/f.txt");
1332        assert_eq!(
1333            relative_path_for_display(&cwd, &target).as_deref(),
1334            Some("f.txt")
1335        );
1336
1337        // Physical cwd after cd -P (private spelling).
1338        let cwd_private = sub.canonicalize_simplified().expect("canonicalize sub");
1339        assert_eq!(
1340            relative_path_for_display(&cwd_private, &target).as_deref(),
1341            Some("f.txt")
1342        );
1343    }
1344
1345    #[test]
1346    fn relative_path_preserves_interior_double_slash_suffix() {
1347        // Mirrors `git rev-parse --git-path info//sparse-checkout`: the suffix
1348        // below the shared prefix is copied verbatim, double slash intact.
1349        let mut sb = String::new();
1350        let rel = relative_path("/repo/.git/info//sparse-checkout", "/repo", &mut sb);
1351        assert_eq!(rel, Some(".git/info//sparse-checkout"));
1352    }
1353
1354    #[test]
1355    fn win32_storage_path_components_round_trip_ref_names() {
1356        let ok = win32_storage_path_components_round_trip;
1357        assert!(!ok("refs/heads/aux"));
1358        assert!(!ok("refs/heads/AUX"));
1359        assert!(!ok("refs/heads/com1"));
1360        assert!(!ok("refs/heads/COM1.txt"));
1361        assert!(!ok("refs/heads/foo/aux/bar"));
1362        assert!(!ok("refs/heads/foo./bar"));
1363        assert!(!ok("refs/heads/conin$"));
1364        assert!(ok("refs/heads/auxiliary"));
1365        assert!(ok("refs/heads/commit"));
1366        assert!(ok("HEAD"));
1367    }
1368}