Skip to main content

grit_lib/
git_path.rs

1//! Git-compatible path normalization and helpers for `test-tool path-utils`.
2//! Logic matches `git/path.c` (`normalize_path_copy`, `longest_ancestor_length`,
3//! `relative_path`, `strip_path_suffix`) and `git/remote.c` (`relative_url`).
4
5use std::path::{Path, PathBuf};
6
7/// Errors returned by Git-compatible path helper routines.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum GitPathError {
10    /// Normalization would escape above the root.
11    EscapesRoot,
12    /// A relative URL cannot be resolved against the provided remote URL.
13    InvalidRelativeUrl,
14}
15
16#[inline]
17fn is_dir_sep(c: u8) -> bool {
18    c == b'/'
19}
20
21/// Purely textual path normalization matching Git's `normalize_path_copy`.
22/// Returns [`GitPathError::EscapesRoot`] when `..` would escape above the root
23/// (Git returns -1).
24pub 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
89/// Git's `stripped_path_suffix_offset` / `strip_path_suffix`.
90pub 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
121/// Git's `longest_ancestor_length` - normalizes `path` and each colon-separated prefix.
122pub 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        // Match git/path.c: need a '/' after the ceiling and another path component (not exact path).
143        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
159/// Git's `relative_path` from `path.c` (POSIX subset).
160pub 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
289/// Git's `relative_url` from `remote.c` (POSIX; no DOS drive handling).
290pub 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/// Whether `path` is an absolute Unix-style path.
352#[must_use]
353pub fn is_absolute_path_unix(path: &str) -> bool {
354    path.starts_with('/')
355}
356
357/// Like Git's `strbuf_realpath` / `test-tool path-utils real_path`: resolve symlinks by
358/// walking path components (so symlink targets are interpreted at each step), then if the
359/// leaf is missing, resolve the longest existing prefix and append the remainder.
360#[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
412/// Git `setup.c` `abspath_part_inside_repo` (POSIX).
413///
414/// Strips the work tree from an absolute, normalized path, preserving symlink path
415/// components when they are still under the work tree as a string prefix.
416pub 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
465/// Git `setup.c` `prefix_path_gently` (POSIX).
466pub 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}