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
383#[must_use]
387pub fn real_path_resolving(path: &str) -> PathBuf {
388 let abs = if path.starts_with('/') {
389 path.to_string()
390 } else {
391 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
392 let joined = format!("{}/{}", cwd.display(), path);
393 normalize_path_copy(&joined).unwrap_or(joined)
394 };
395 let p = Path::new(&abs);
396 if let Ok(c) = p.canonicalize() {
397 return c;
398 }
399 let mut cur = PathBuf::from("/");
400 for part in abs.trim_start_matches('/').split('/') {
401 if part.is_empty() {
402 continue;
403 }
404 cur.push(part);
405 if let Ok(c) = cur.canonicalize() {
406 cur = c;
407 } else if let Ok(target) = std::fs::read_link(&cur) {
408 cur.pop();
409 cur.push(target);
410 if let Ok(c) = cur.canonicalize() {
411 cur = c;
412 }
413 }
414 }
415 if cur.exists() {
416 return cur;
417 }
418 let mut base = cur.clone();
419 let mut missing = Vec::new();
420 while !base.as_os_str().is_empty() && !base.exists() {
421 missing.push(base.file_name().unwrap_or_default().to_owned());
422 if !base.pop() {
423 break;
424 }
425 }
426 if base.as_os_str().is_empty() {
427 base = PathBuf::from("/");
428 }
429 let Ok(mut resolved) = base.canonicalize() else {
430 return cur;
431 };
432 while let Some(name) = missing.pop() {
433 resolved.push(name);
434 }
435 resolved
436}
437
438pub fn abspath_part_inside_repo(path: &str, work_tree: &Path) -> Option<String> {
443 let normalized = normalize_path_copy(path).ok()?;
444 if !normalized.starts_with('/') {
445 return None;
446 }
447 let wt_display = work_tree.to_string_lossy();
448 let wt_trim: &str = if wt_display == "/" {
449 "/"
450 } else {
451 wt_display.trim_end_matches('/')
452 };
453 let wt_len = wt_trim.len();
454 let p = normalized.as_str();
455 let len = p.len();
456
457 if wt_len <= len && p.starts_with(wt_trim) {
458 if len > wt_len && p.as_bytes()[wt_len] == b'/' {
459 return Some(p[wt_len + 1..].to_string());
460 }
461 if len == wt_len {
462 return Some(String::new());
463 }
464 if wt_len > 0 && wt_trim.as_bytes()[wt_len - 1] == b'/' {
465 return Some(p[wt_len..].trim_start_matches('/').to_string());
466 }
467 }
468
469 let wt_canon = path_for_disk_compare(work_tree);
470 let mut cum = String::new();
471 for seg in p.split('/').filter(|s| !s.is_empty()) {
472 cum.push('/');
473 cum.push_str(seg);
474 let rp = path_for_disk_compare(Path::new(&cum));
475 if rp == wt_canon {
476 if p.len() == cum.len() {
477 return Some(String::new());
478 }
479 if p.as_bytes().get(cum.len()) == Some(&b'/') {
480 return Some(p[cum.len() + 1..].to_string());
481 }
482 }
483 }
484 let full = path_for_disk_compare(Path::new(p));
485 if full == wt_canon {
486 return Some(String::new());
487 }
488 None
489}
490
491#[must_use]
496pub fn path_for_disk_compare(path: &Path) -> PathBuf {
497 let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
498 #[cfg(target_os = "macos")]
499 {
500 if let Ok(stripped) = canon.strip_prefix("/private") {
501 let without_private = PathBuf::from("/").join(stripped);
502 if without_private.exists() {
503 return without_private;
504 }
505 }
506 }
507 canon
508}
509
510pub fn prefix_path_gently(prefix: &str, path: &str, work_tree: &Path) -> Option<String> {
512 if path.starts_with('/') {
513 let n = normalize_path_copy(path).ok()?;
514 abspath_part_inside_repo(&n, work_tree)
515 } else {
516 let concat = format!("{prefix}{path}");
517 normalize_path_copy(&concat).ok()
518 }
519}
520
521#[cfg(test)]
522mod git_path_component_tests {
523 use super::*;
524
525 #[test]
526 fn cleanup_path_strips_leading_dot_slash() {
527 assert_eq!(cleanup_path("./foo"), "foo");
528 assert_eq!(cleanup_path(".//foo"), "foo");
529 assert_eq!(cleanup_path("foo"), "foo");
530 }
531
532 #[test]
533 fn cleanup_path_keeps_internal_double_slashes() {
534 assert_eq!(
536 cleanup_path("info//sparse-checkout"),
537 "info//sparse-checkout"
538 );
539 assert_eq!(cleanup_path("./info//grafts"), "info//grafts");
540 }
541
542 #[test]
543 fn git_path_component_drops_one_leading_slash_keeps_interior() {
544 assert_eq!(
545 git_path_relative_component("info//sparse-checkout"),
546 "info//sparse-checkout"
547 );
548 assert_eq!(git_path_relative_component("/info//grafts"), "info//grafts");
549 assert_eq!(git_path_relative_component("HEAD"), "HEAD");
550 }
551
552 #[test]
553 fn relative_path_preserves_interior_double_slash_suffix() {
554 let mut sb = String::new();
557 let rel = relative_path("/repo/.git/info//sparse-checkout", "/repo", &mut sb);
558 assert_eq!(rel, Some(".git/info//sparse-checkout"));
559 }
560}