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]
361pub fn real_path_resolving(path: &str) -> PathBuf {
362 let abs = if path.starts_with('/') {
363 path.to_string()
364 } else {
365 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
366 let joined = format!("{}/{}", cwd.display(), path);
367 normalize_path_copy(&joined).unwrap_or(joined)
368 };
369 let p = Path::new(&abs);
370 if let Ok(c) = p.canonicalize() {
371 return c;
372 }
373 let mut cur = PathBuf::from("/");
374 for part in abs.trim_start_matches('/').split('/') {
375 if part.is_empty() {
376 continue;
377 }
378 cur.push(part);
379 if let Ok(c) = cur.canonicalize() {
380 cur = c;
381 } else if let Ok(target) = std::fs::read_link(&cur) {
382 cur.pop();
383 cur.push(target);
384 if let Ok(c) = cur.canonicalize() {
385 cur = c;
386 }
387 }
388 }
389 if cur.exists() {
390 return cur;
391 }
392 let mut base = cur.clone();
393 let mut missing = Vec::new();
394 while !base.as_os_str().is_empty() && !base.exists() {
395 missing.push(base.file_name().unwrap_or_default().to_owned());
396 if !base.pop() {
397 break;
398 }
399 }
400 if base.as_os_str().is_empty() {
401 base = PathBuf::from("/");
402 }
403 let Ok(mut resolved) = base.canonicalize() else {
404 return cur;
405 };
406 while let Some(name) = missing.pop() {
407 resolved.push(name);
408 }
409 resolved
410}
411
412pub fn abspath_part_inside_repo(path: &str, work_tree: &Path) -> Option<String> {
417 let normalized = normalize_path_copy(path).ok()?;
418 if !normalized.starts_with('/') {
419 return None;
420 }
421 let wt_display = work_tree.to_string_lossy();
422 let wt_trim: &str = if wt_display == "/" {
423 "/"
424 } else {
425 wt_display.trim_end_matches('/')
426 };
427 let wt_len = wt_trim.len();
428 let p = normalized.as_str();
429 let len = p.len();
430
431 if wt_len <= len && p.starts_with(wt_trim) {
432 if len > wt_len && p.as_bytes()[wt_len] == b'/' {
433 return Some(p[wt_len + 1..].to_string());
434 }
435 if len == wt_len {
436 return Some(String::new());
437 }
438 if wt_len > 0 && wt_trim.as_bytes()[wt_len - 1] == b'/' {
439 return Some(p[wt_len..].trim_start_matches('/').to_string());
440 }
441 }
442
443 let wt_canon = std::fs::canonicalize(work_tree).ok()?;
444 let mut cum = String::new();
445 for seg in p.split('/').filter(|s| !s.is_empty()) {
446 cum.push('/');
447 cum.push_str(seg);
448 let rp = std::fs::canonicalize(Path::new(&cum)).ok()?;
449 if rp == wt_canon {
450 if p.len() == cum.len() {
451 return Some(String::new());
452 }
453 if p.as_bytes().get(cum.len()) == Some(&b'/') {
454 return Some(p[cum.len() + 1..].to_string());
455 }
456 }
457 }
458 let full = std::fs::canonicalize(Path::new(p)).ok()?;
459 if full == wt_canon {
460 return Some(String::new());
461 }
462 None
463}
464
465pub fn prefix_path_gently(prefix: &str, path: &str, work_tree: &Path) -> Option<String> {
467 if path.starts_with('/') {
468 let n = normalize_path_copy(path).ok()?;
469 abspath_part_inside_repo(&n, work_tree)
470 } else {
471 let concat = format!("{prefix}{path}");
472 normalize_path_copy(&concat).ok()
473 }
474}