1use std::path::{Path, PathBuf};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum GitPathError {
10 EscapesRoot,
12 InvalidRelativeUrl,
14}
15
16#[inline]
17fn is_dir_sep(c: u8) -> bool {
18 c == b'/'
19}
20
21pub fn normalize_path_copy(src: &str) -> Result<String, GitPathError> {
25 let is_abs = src.starts_with('/');
26 let raw_ends_dir = {
27 let stripped = src.trim_end_matches('/');
28 stripped.ends_with("/.")
29 || stripped.ends_with("/..")
30 || src.ends_with('/')
31 || src == "."
32 || src == ".."
33 };
34 let trailing_slash = raw_ends_dir && !src.is_empty();
35 let mut stack: Vec<String> = Vec::new();
36 let bytes = src.as_bytes();
37 let mut i = 0usize;
38 if is_abs {
39 i = 1;
40 }
41 while i < bytes.len() {
42 while i < bytes.len() && bytes[i] == b'/' {
43 i += 1;
44 }
45 if i >= bytes.len() {
46 break;
47 }
48 let start = i;
49 while i < bytes.len() && bytes[i] != b'/' {
50 i += 1;
51 }
52 let part = &src[start..i];
53 if part == "." {
54 continue;
55 }
56 if part == ".." {
57 if stack.pop().is_none() {
58 return Err(GitPathError::EscapesRoot);
59 }
60 } else {
61 stack.push(part.to_string());
62 }
63 }
64
65 let mut out = if is_abs {
66 if stack.is_empty() {
67 "/".to_string()
68 } else {
69 "/".to_string() + &stack.join("/")
70 }
71 } else if stack.is_empty() {
72 String::new()
73 } else {
74 stack.join("/")
75 };
76 if trailing_slash && !out.is_empty() && !out.ends_with('/') {
77 out.push('/');
78 }
79 Ok(out)
80}
81
82fn chomp_trailing_dir_sep(path: &[u8], mut len: usize) -> usize {
83 while len > 0 && is_dir_sep(path[len - 1]) {
84 len -= 1;
85 }
86 len
87}
88
89pub fn strip_path_suffix(path: &str, suffix: &str) -> Option<String> {
91 let path = path.as_bytes();
92 let suffix = suffix.as_bytes();
93 let mut path_len = path.len();
94 let mut suffix_len = suffix.len();
95
96 while suffix_len > 0 {
97 if path_len == 0 {
98 return None;
99 }
100 if is_dir_sep(path[path_len - 1]) {
101 if !is_dir_sep(suffix[suffix_len - 1]) {
102 return None;
103 }
104 path_len = chomp_trailing_dir_sep(path, path_len);
105 suffix_len = chomp_trailing_dir_sep(suffix, suffix_len);
106 } else if path[path_len - 1] != suffix[suffix_len - 1] {
107 return None;
108 } else {
109 path_len -= 1;
110 suffix_len -= 1;
111 }
112 }
113
114 if path_len > 0 && !is_dir_sep(path[path_len - 1]) {
115 return None;
116 }
117 let off = chomp_trailing_dir_sep(path, path_len);
118 Some(String::from_utf8_lossy(&path[..off]).into_owned())
119}
120
121pub fn longest_ancestor_length(path: &str, prefixes_colon_sep: &str) -> Result<i32, GitPathError> {
123 let path = normalize_path_copy(path)?;
124 if path == "/" {
125 return Ok(-1);
126 }
127 let mut max_len: i64 = -1;
128 for ceil_raw in prefixes_colon_sep.split(':') {
129 if ceil_raw.is_empty() {
130 continue;
131 }
132 let ceil = normalize_path_copy(ceil_raw)?;
133 let mut len = ceil.len();
134 if len > 0 && ceil.as_bytes()[len - 1] == b'/' {
135 len -= 1;
136 }
137 let p = path.as_bytes();
138 let c = ceil.as_bytes();
139 if len > p.len() || len > c.len() || p[..len] != c[..len] {
140 continue;
141 }
142 if len == p.len() || p[len] != b'/' || p.get(len + 1).is_none() {
144 continue;
145 }
146 if len as i64 > max_len {
147 max_len = len as i64;
148 }
149 }
150 Ok(max_len as i32)
151}
152
153fn have_same_root(path1: &str, path2: &str) -> bool {
154 let abs1 = path1.starts_with('/');
155 let abs2 = path2.starts_with('/');
156 (abs1 && abs2) || (!abs1 && !abs2)
157}
158
159pub fn relative_path<'a>(in_path: &'a str, prefix: &'a str, sb: &'a mut String) -> Option<&'a str> {
161 let in_len = in_path.len();
162 let prefix_len = prefix.len();
163 let mut in_off = 0usize;
164 let mut prefix_off = 0usize;
165 let mut i = 0usize;
166 let mut j = 0usize;
167
168 if in_len == 0 {
169 return Some("./");
170 }
171 if prefix_len == 0 {
172 return Some(in_path);
173 }
174
175 if !have_same_root(in_path, prefix) {
176 return Some(in_path);
177 }
178
179 let in_b = in_path.as_bytes();
180 let pre_b = prefix.as_bytes();
181
182 while i < prefix_len && j < in_len && pre_b[i] == in_b[j] {
183 if is_dir_sep(pre_b[i]) {
184 while i < prefix_len && is_dir_sep(pre_b[i]) {
185 i += 1;
186 }
187 while j < in_len && is_dir_sep(in_b[j]) {
188 j += 1;
189 }
190 prefix_off = i;
191 in_off = j;
192 } else {
193 i += 1;
194 j += 1;
195 }
196 }
197
198 if i >= prefix_len && prefix_off < prefix_len {
199 if j >= in_len {
200 in_off = in_len;
201 } else if is_dir_sep(in_b[j]) {
202 while j < in_len && is_dir_sep(in_b[j]) {
203 j += 1;
204 }
205 in_off = j;
206 } else {
207 i = prefix_off;
208 }
209 } else if j >= in_len && in_off < in_len && is_dir_sep(pre_b[i]) {
210 while i < prefix_len && is_dir_sep(pre_b[i]) {
211 i += 1;
212 }
213 in_off = in_len;
214 }
215
216 let in_suffix = &in_path[in_off..];
217 let in_suffix_len = in_suffix.len();
218
219 if i >= prefix_len {
220 if in_suffix_len == 0 {
221 return Some("./");
222 }
223 return Some(in_suffix);
224 }
225
226 sb.clear();
227 sb.reserve(in_suffix_len.saturating_add(prefix_len * 3));
228
229 while i < prefix_len {
230 if is_dir_sep(pre_b[i]) {
231 sb.push_str("../");
232 while i < prefix_len && is_dir_sep(pre_b[i]) {
233 i += 1;
234 }
235 continue;
236 }
237 i += 1;
238 }
239 if prefix_len > 0 && !is_dir_sep(pre_b[prefix_len - 1]) {
240 sb.push_str("../");
241 }
242 sb.push_str(in_suffix);
243
244 Some(sb.as_str())
245}
246
247fn find_last_dir_sep(path: &str) -> Option<usize> {
248 path.rfind('/')
249}
250
251fn chop_last_dir(remoteurl: &mut String, is_relative: bool) -> Result<bool, GitPathError> {
252 if let Some(pos) = find_last_dir_sep(remoteurl.as_str()) {
253 remoteurl.truncate(pos);
254 return Ok(false);
255 }
256 if let Some(pos) = remoteurl.rfind(':') {
257 remoteurl.truncate(pos);
258 return Ok(true);
259 }
260 if is_relative || remoteurl == "." {
261 return Err(GitPathError::InvalidRelativeUrl);
262 }
263 *remoteurl = ".".to_string();
264 Ok(false)
265}
266
267fn url_is_local_not_ssh(url: &str) -> bool {
268 let colon = url.find(':');
269 let slash = url.find('/');
270 match (colon, slash) {
271 (None, _) => true,
272 (Some(ci), Some(si)) if si < ci => true,
273 _ => false,
274 }
275}
276
277fn starts_with_dot_slash_native(s: &str) -> bool {
278 s.starts_with("./")
279}
280
281fn starts_with_dot_dot_slash_native(s: &str) -> bool {
282 s.starts_with("../")
283}
284
285fn ends_with_slash(url: &str) -> bool {
286 url.ends_with('/')
287}
288
289pub fn relative_url(
291 remote_url: &str,
292 url: &str,
293 up_path: Option<&str>,
294) -> Result<String, GitPathError> {
295 if !url_is_local_not_ssh(url) || url.starts_with('/') {
296 return Ok(url.to_string());
297 }
298
299 let mut remoteurl = remote_url.to_string();
300 let len = remoteurl.len();
301 if len == 0 {
302 return Err(GitPathError::InvalidRelativeUrl);
303 }
304 if remoteurl.ends_with('/') {
305 remoteurl.truncate(len - 1);
306 }
307
308 let is_relative = if !url_is_local_not_ssh(&remoteurl) || remoteurl.starts_with('/') {
309 false
310 } else {
311 if !starts_with_dot_slash_native(&remoteurl)
312 && !starts_with_dot_dot_slash_native(&remoteurl)
313 {
314 remoteurl = format!("./{remoteurl}");
315 }
316 true
317 };
318
319 let mut url_rest = url;
320 let mut colonsep = false;
321 while !url_rest.is_empty() {
322 if starts_with_dot_dot_slash_native(url_rest) {
323 url_rest = &url_rest[3..];
324 let seg = chop_last_dir(&mut remoteurl, is_relative)?;
325 colonsep |= seg;
326 } else if starts_with_dot_slash_native(url_rest) {
327 url_rest = &url_rest[2..];
328 } else {
329 break;
330 }
331 }
332
333 let sep = if colonsep { ":" } else { "/" };
334 let mut combined = format!("{remoteurl}{sep}{url_rest}");
335 if ends_with_slash(url) && combined.ends_with('/') {
336 combined.pop();
337 }
338
339 let out = if starts_with_dot_slash_native(&combined) {
340 combined[2..].to_string()
341 } else {
342 combined
343 };
344
345 match up_path {
346 Some(up) if is_relative => Ok(format!("{up}{out}")),
347 _ => Ok(out),
348 }
349}
350
351#[must_use]
353pub fn is_absolute_path_unix(path: &str) -> bool {
354 path.starts_with('/')
355}
356
357#[must_use]
362pub fn cleanup_path(path: &str) -> &str {
363 if let Some(rest) = path.strip_prefix("./") {
364 rest.trim_start_matches('/')
365 } else {
366 path
367 }
368}
369
370#[must_use]
376pub fn git_path_relative_component(path: &str) -> &str {
377 let trimmed = path.strip_prefix('/').unwrap_or(path);
380 cleanup_path(trimmed)
381}
382
383struct CommonDir {
385 is_dir: bool,
386 is_common: bool,
387 path: &'static str,
388}
389
390const COMMON_LIST: &[CommonDir] = &[
395 CommonDir {
396 is_dir: true,
397 is_common: true,
398 path: "branches",
399 },
400 CommonDir {
401 is_dir: true,
402 is_common: true,
403 path: "common",
404 },
405 CommonDir {
406 is_dir: true,
407 is_common: true,
408 path: "hooks",
409 },
410 CommonDir {
411 is_dir: true,
412 is_common: true,
413 path: "info",
414 },
415 CommonDir {
416 is_dir: false,
417 is_common: false,
418 path: "info/sparse-checkout",
419 },
420 CommonDir {
421 is_dir: true,
422 is_common: true,
423 path: "logs",
424 },
425 CommonDir {
426 is_dir: false,
427 is_common: false,
428 path: "logs/HEAD",
429 },
430 CommonDir {
431 is_dir: true,
432 is_common: false,
433 path: "logs/refs/bisect",
434 },
435 CommonDir {
436 is_dir: true,
437 is_common: false,
438 path: "logs/refs/rewritten",
439 },
440 CommonDir {
441 is_dir: true,
442 is_common: false,
443 path: "logs/refs/worktree",
444 },
445 CommonDir {
446 is_dir: true,
447 is_common: true,
448 path: "lost-found",
449 },
450 CommonDir {
451 is_dir: true,
452 is_common: true,
453 path: "objects",
454 },
455 CommonDir {
456 is_dir: true,
457 is_common: true,
458 path: "refs",
459 },
460 CommonDir {
461 is_dir: true,
462 is_common: false,
463 path: "refs/bisect",
464 },
465 CommonDir {
466 is_dir: true,
467 is_common: false,
468 path: "refs/rewritten",
469 },
470 CommonDir {
471 is_dir: true,
472 is_common: false,
473 path: "refs/worktree",
474 },
475 CommonDir {
476 is_dir: true,
477 is_common: true,
478 path: "remotes",
479 },
480 CommonDir {
481 is_dir: true,
482 is_common: true,
483 path: "worktrees",
484 },
485 CommonDir {
486 is_dir: true,
487 is_common: true,
488 path: "rr-cache",
489 },
490 CommonDir {
491 is_dir: true,
492 is_common: true,
493 path: "svn",
494 },
495 CommonDir {
496 is_dir: false,
497 is_common: true,
498 path: "config",
499 },
500 CommonDir {
501 is_dir: false,
502 is_common: true,
503 path: "gc.pid",
504 },
505 CommonDir {
506 is_dir: false,
507 is_common: true,
508 path: "packed-refs",
509 },
510 CommonDir {
511 is_dir: false,
512 is_common: true,
513 path: "shallow",
514 },
515];
516
517fn check_common(entry: &CommonDir, unmatched: &[u8]) -> Option<bool> {
520 let first = unmatched.first().copied();
521 if entry.is_dir && (first.is_none() || first == Some(b'/')) {
522 return Some(entry.is_common);
523 }
524 if !entry.is_dir && first.is_none() {
525 return Some(entry.is_common);
526 }
527 None
528}
529
530fn trie_find_common(key: &[u8]) -> Option<bool> {
539 let norm = normalize_double_slashes(key);
546 let mut matches: Vec<&CommonDir> = COMMON_LIST
548 .iter()
549 .filter(|e| key_has_prefix_node(&norm, e.path.as_bytes()))
550 .collect();
551 matches.sort_by(|a, b| b.path.len().cmp(&a.path.len()));
552 for entry in matches {
553 let plen = entry.path.len();
554 let unmatched = &norm[plen..];
555 if let Some(verdict) = check_common(entry, unmatched) {
556 return Some(verdict);
557 }
558 }
561 None
562}
563
564fn normalize_double_slashes(key: &[u8]) -> Vec<u8> {
568 let mut out = Vec::with_capacity(key.len());
569 let mut prev_slash = false;
570 for &b in key {
571 if b == b'/' {
572 if !prev_slash {
573 out.push(b);
574 }
575 prev_slash = true;
576 } else {
577 out.push(b);
578 prev_slash = false;
579 }
580 }
581 out
582}
583
584fn key_has_prefix_node(key: &[u8], node: &[u8]) -> bool {
586 if key.len() < node.len() || &key[..node.len()] != node {
587 return false;
588 }
589 matches!(key.get(node.len()), None | Some(b'/'))
590}
591
592#[must_use]
599pub fn is_common_git_path(rel: &str) -> bool {
600 let stripped = rel.strip_suffix(".lock").unwrap_or(rel);
601 matches!(trie_find_common(stripped.as_bytes()), Some(true))
602}
603
604#[must_use]
608pub fn real_path_resolving(path: &str) -> PathBuf {
609 let abs = if path.starts_with('/') {
610 path.to_string()
611 } else {
612 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
613 let joined = format!("{}/{}", cwd.display(), path);
614 normalize_path_copy(&joined).unwrap_or(joined)
615 };
616 let p = Path::new(&abs);
617 if let Ok(c) = p.canonicalize() {
618 return c;
619 }
620 let mut cur = PathBuf::from("/");
621 for part in abs.trim_start_matches('/').split('/') {
622 if part.is_empty() {
623 continue;
624 }
625 cur.push(part);
626 if let Ok(c) = cur.canonicalize() {
627 cur = c;
628 } else if let Ok(target) = std::fs::read_link(&cur) {
629 cur.pop();
630 cur.push(target);
631 if let Ok(c) = cur.canonicalize() {
632 cur = c;
633 }
634 }
635 }
636 if cur.exists() {
637 return cur;
638 }
639 let mut base = cur.clone();
640 let mut missing = Vec::new();
641 while !base.as_os_str().is_empty() && !base.exists() {
642 missing.push(base.file_name().unwrap_or_default().to_owned());
643 if !base.pop() {
644 break;
645 }
646 }
647 if base.as_os_str().is_empty() {
648 base = PathBuf::from("/");
649 }
650 let Ok(mut resolved) = base.canonicalize() else {
651 return cur;
652 };
653 while let Some(name) = missing.pop() {
654 resolved.push(name);
655 }
656 resolved
657}
658
659pub fn abspath_part_inside_repo(path: &str, work_tree: &Path) -> Option<String> {
664 let normalized = normalize_path_copy(path).ok()?;
665 if !normalized.starts_with('/') {
666 return None;
667 }
668 let wt_display = work_tree.to_string_lossy();
669 let wt_trim: &str = if wt_display == "/" {
670 "/"
671 } else {
672 wt_display.trim_end_matches('/')
673 };
674 let wt_len = wt_trim.len();
675 let p = normalized.as_str();
676 let len = p.len();
677
678 if wt_len <= len && p.starts_with(wt_trim) {
679 if len > wt_len && p.as_bytes()[wt_len] == b'/' {
680 return Some(p[wt_len + 1..].to_string());
681 }
682 if len == wt_len {
683 return Some(String::new());
684 }
685 if wt_len > 0 && wt_trim.as_bytes()[wt_len - 1] == b'/' {
686 return Some(p[wt_len..].trim_start_matches('/').to_string());
687 }
688 }
689
690 let wt_canon = path_for_disk_compare(work_tree);
691 let mut cum = String::new();
692 for seg in p.split('/').filter(|s| !s.is_empty()) {
693 cum.push('/');
694 cum.push_str(seg);
695 let rp = path_for_disk_compare(Path::new(&cum));
696 if rp == wt_canon {
697 if p.len() == cum.len() {
698 return Some(String::new());
699 }
700 if p.as_bytes().get(cum.len()) == Some(&b'/') {
701 return Some(p[cum.len() + 1..].to_string());
702 }
703 }
704 }
705 let full = path_for_disk_compare(Path::new(p));
706 if full == wt_canon {
707 return Some(String::new());
708 }
709 None
710}
711
712#[must_use]
717pub fn path_for_disk_compare(path: &Path) -> PathBuf {
718 let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
719 #[cfg(target_os = "macos")]
720 {
721 if let Ok(stripped) = canon.strip_prefix("/private") {
722 let without_private = PathBuf::from("/").join(stripped);
723 if without_private.exists() {
724 return without_private;
725 }
726 }
727 }
728 canon
729}
730
731pub fn prefix_path_gently(prefix: &str, path: &str, work_tree: &Path) -> Option<String> {
733 if path.starts_with('/') {
734 let n = normalize_path_copy(path).ok()?;
735 abspath_part_inside_repo(&n, work_tree)
736 } else {
737 let concat = format!("{prefix}{path}");
738 normalize_path_copy(&concat).ok()
739 }
740}
741
742#[cfg(test)]
743mod git_path_component_tests {
744 use super::*;
745
746 #[test]
747 fn cleanup_path_strips_leading_dot_slash() {
748 assert_eq!(cleanup_path("./foo"), "foo");
749 assert_eq!(cleanup_path(".//foo"), "foo");
750 assert_eq!(cleanup_path("foo"), "foo");
751 }
752
753 #[test]
754 fn cleanup_path_keeps_internal_double_slashes() {
755 assert_eq!(
757 cleanup_path("info//sparse-checkout"),
758 "info//sparse-checkout"
759 );
760 assert_eq!(cleanup_path("./info//grafts"), "info//grafts");
761 }
762
763 #[test]
764 fn git_path_component_drops_one_leading_slash_keeps_interior() {
765 assert_eq!(
766 git_path_relative_component("info//sparse-checkout"),
767 "info//sparse-checkout"
768 );
769 assert_eq!(git_path_relative_component("/info//grafts"), "info//grafts");
770 assert_eq!(git_path_relative_component("HEAD"), "HEAD");
771 }
772
773 #[test]
774 fn is_common_git_path_matches_git_common_list() {
775 for p in [
777 "logs/refs",
778 "logs/refs/",
779 "logs/refs/bisec/foo",
780 "logs/refs/bisec",
781 "logs/refs/bisectfoo",
782 "objects",
783 "objects/bar",
784 "info/exclude",
785 "info/grafts",
786 "remotes/bar",
787 "branches/bar",
788 "logs/refs/heads/main",
789 "refs/heads/main",
790 "hooks/me",
791 "config",
792 "packed-refs",
793 "shallow",
794 "common",
795 "common/file",
796 ] {
797 assert!(is_common_git_path(p), "{p} should be common");
798 }
799 for p in [
801 "index",
802 "index.lock",
803 "HEAD",
804 "logs/HEAD",
805 "logs/HEAD.lock",
806 "logs/refs/bisect/foo",
807 "info/sparse-checkout",
808 "refs/bisect/foo",
809 ] {
810 assert!(!is_common_git_path(p), "{p} should be worktree-local");
811 }
812 }
813
814 #[test]
815 fn relative_path_preserves_interior_double_slash_suffix() {
816 let mut sb = String::new();
819 let rel = relative_path("/repo/.git/info//sparse-checkout", "/repo", &mut sb);
820 assert_eq!(rel, Some(".git/info//sparse-checkout"));
821 }
822}