1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum GitPathError {
16 EscapesRoot,
18 InvalidRelativeUrl,
20}
21
22#[inline]
23fn is_dir_sep(c: u8) -> bool {
24 c == b'/'
25}
26
27pub 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
88pub 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 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
126pub 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#[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#[must_use]
233pub fn git_path_relative_component(path: &str) -> &str {
234 let trimmed = path.strip_prefix('/').unwrap_or(path);
237 cleanup_path(trimmed)
238}
239
240struct CommonDir {
242 is_dir: bool,
243 is_common: bool,
244 path: &'static str,
245}
246
247const 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
374fn 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
387fn trie_find_common(key: &[u8]) -> Option<bool> {
396 let norm = normalize_double_slashes(key);
403 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 }
418 None
419}
420
421fn 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
441fn 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#[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
461pub 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
514pub(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
523pub(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#[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#[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#[cfg(any(windows, test))]
570const WIN32_MAX_PATH_UNITS: usize = 259;
571
572#[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#[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#[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#[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
663pub(crate) trait CanonicalizeExt {
668 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#[must_use]
686pub fn user_display_path(path: &Path) -> String {
687 strip_verbatim_path_prefix_str(&path.display().to_string()).replace('\\', "/")
688}
689
690#[must_use]
692pub fn path_for_error_message(path: &Path) -> String {
693 user_display_path(path)
694}
695
696#[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#[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#[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#[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#[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#[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#[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#[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
901pub 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 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 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 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 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 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 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}