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/// Git's `cleanup_path` from `path.c`: strip a single leading `./` and any
358/// directory separators immediately following it. Internal consecutive slashes
359/// (e.g. `info//sparse-checkout`) are deliberately preserved so the result
360/// matches `git rev-parse --git-path` byte-for-byte.
361#[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/// The relative portion of a `--git-path` argument, mirroring how Git builds the
371/// buffer in `repo_git_pathv`: the caller-supplied `fmt` string is appended to
372/// `<git_dir>/` verbatim and only [`cleanup_path`] runs over the whole buffer.
373/// In practice that means a single leading `/` (and a leading `./`) is dropped
374/// from the user-supplied component while internal `//` runs are kept intact.
375#[must_use]
376pub fn git_path_relative_component(path: &str) -> &str {
377    // Drop one leading slash (Git appends fmt right after "<git_dir>/", so a
378    // user "/foo" would otherwise become "<git_dir>//foo"); keep the rest as-is.
379    let trimmed = path.strip_prefix('/').unwrap_or(path);
380    cleanup_path(trimmed)
381}
382
383/// One entry in Git's `common_list` (`git/path.c`).
384struct CommonDir {
385    is_dir: bool,
386    is_common: bool,
387    path: &'static str,
388}
389
390/// Git's `common_list` table from `git/path.c`. Each entry classifies a path
391/// (or directory prefix) under the git dir as belonging to the common dir or to
392/// the per-worktree git dir. The order is irrelevant for classification because
393/// `trie_find` always selects the longest `/`-terminated matching prefix.
394const 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
517/// Git's `check_common` (`git/path.c`): decide, for the matched `common_list`
518/// entry and the unmatched remainder of the key, whether the path is common.
519fn 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
530/// Compressed-trie lookup with the same behavior as Git's `trie_find`, specialized to
531/// `common_list` + `check_common`. Returns the longest `/`-or-`\0`-terminated
532/// `common_list` prefix's classification for `key`, or `None` if no prefix
533/// matches (treated as "not common" by callers).
534///
535/// Mirrors the C trie semantics including: partial normalization (consecutive
536/// slashes are skipped) and the fallback to a shorter `/`-terminated prefix when
537/// a longer node yields no verdict.
538fn trie_find_common(key: &[u8]) -> Option<bool> {
539    // The trie distinguishes nodes by their full path; longest match wins, but
540    // when the deepest matching node declines (`check_common` -> None) and we are
541    // at a `/` boundary, control falls back to the next-shorter prefix that has a
542    // value. We emulate this by scanning candidate prefixes from longest to
543    // shortest. A candidate is a `common_list` path P that is a prefix of the
544    // normalized key, terminated in the key by `\0` or `/`.
545    let norm = normalize_double_slashes(key);
546    // Collect matching entries, longest path first.
547    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        // No verdict at this node. The C trie only falls back to a shorter
559        // prefix; continue to the next (shorter) candidate.
560    }
561    None
562}
563
564/// Collapse runs of consecutive `/` to a single `/` (Git's partial path
565/// normalization inside `trie_find`). A trailing slash is preserved as a single
566/// slash so directory-prefix matching still sees the boundary.
567fn 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
584/// True when `node` is a prefix of `key` terminated by end-of-string or `/`.
585fn 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/// True when the relative git-dir path `rel` belongs to the common (shared)
593/// directory, mirroring Git's `update_common_dir` decision (`git/path.c`).
594///
595/// `rel` is the component after the git dir (e.g. `logs/refs`, `config`,
596/// `HEAD`). Any trailing `.lock` suffix is ignored for the decision, exactly as
597/// `update_common_dir` strips `LOCK_SUFFIX` before consulting the trie.
598#[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/// Like Git's `strbuf_realpath` / `test-tool path-utils real_path`: resolve symlinks by
605/// walking path components (so symlink targets are interpreted at each step), then if the
606/// leaf is missing, resolve the longest existing prefix and append the remainder.
607#[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
659/// Git `setup.c` `abspath_part_inside_repo` (POSIX).
660///
661/// Strips the work tree from an absolute, normalized path, preserving symlink path
662/// components when they are still under the work tree as a string prefix.
663pub 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/// Canonicalize a path for on-disk comparison (macOS `/private` aliasing).
713///
714/// On macOS, `/tmp` and `/private/tmp` refer to the same directory; Git stores and
715/// accepts both spellings when matching paths against `core.worktree`.
716#[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
731/// Git `setup.c` `prefix_path_gently` (POSIX).
732pub 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        // Git's cleanup_path never collapses interior consecutive slashes.
756        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        // Common (resolved against the common dir) — t0060 cases.
776        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        // Per-worktree (resolved against the git dir) — t0060 cases.
800        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        // Mirrors `git rev-parse --git-path info//sparse-checkout`: the suffix
817        // below the shared prefix is copied verbatim, double slash intact.
818        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}