Skip to main content

sley_core/
paths.rs

1//! Canonical shared path helpers (phase-2 consolidation).
2//!
3//! Single home for:
4//! - the faithful port of git's `relative_path()` (path.c) over raw bytes,
5//! - lexical (no-filesystem) normalization and absolute↔relative computation,
6//! - bytes↔path conversions matching git's byte-oriented path handling.
7//!
8//! Everything here is pure string/component math unless documented otherwise;
9//! only [`relative_path_from_absolute`] touches the filesystem.
10
11use std::ffi::OsStr;
12use std::fs;
13use std::path::{Component, Path, PathBuf};
14
15use crate::{GitError, Result};
16
17// ---------------------------------------------------------------------------
18// git path.c relative_path() — faithful byte-level port
19// ---------------------------------------------------------------------------
20
21/// Render `input` relative to `prefix`, a faithful byte-level port of git's
22/// `relative_path()` (path.c) for the POSIX, both-relative case (no DOS drive).
23/// `prefix` is the cwd prefix and must end with `/` when non-empty, matching
24/// git's `cmd_prefix`. Emits `../` for each `prefix` component not shared with
25/// `input`, then the unshared tail of `input`.
26///
27// `i` and `j` are independent cursors because repeated separators can advance
28// the prefix and input by different amounts.
29#[allow(clippy::suspicious_operation_groupings)]
30pub fn relative_path_bytes(input: &[u8], prefix: &[u8]) -> Vec<u8> {
31    let in_len = input.len();
32    let prefix_len = prefix.len();
33    if in_len == 0 {
34        return b"./".to_vec();
35    }
36    if prefix_len == 0 {
37        return input.to_vec();
38    }
39    let is_sep = |byte: u8| byte == b'/';
40    let mut i = 0usize;
41    let mut j = 0usize;
42    let mut prefix_off = 0usize;
43    let mut in_off = 0usize;
44    while i < prefix_len && j < in_len && prefix.get(i) == input.get(j) {
45        if is_sep(prefix[i]) {
46            while i < prefix_len && is_sep(prefix[i]) {
47                i += 1;
48            }
49            while j < in_len && is_sep(input[j]) {
50                j += 1;
51            }
52            prefix_off = i;
53            in_off = j;
54        } else {
55            i += 1;
56            j += 1;
57        }
58    }
59
60    if i >= prefix_len && prefix_off < prefix_len {
61        if j >= in_len {
62            in_off = in_len;
63        } else if is_sep(input[j]) {
64            while j < in_len && is_sep(input[j]) {
65                j += 1;
66            }
67            in_off = j;
68        } else {
69            i = prefix_off;
70        }
71    } else if j >= in_len && in_off < in_len && i < prefix_len && is_sep(prefix[i]) {
72        while i < prefix_len && is_sep(prefix[i]) {
73            i += 1;
74        }
75        in_off = in_len;
76    }
77
78    let input = &input[in_off..];
79    if i >= prefix_len {
80        if input.is_empty() {
81            return b"./".to_vec();
82        }
83        return input.to_vec();
84    }
85
86    let mut out = Vec::new();
87    while i < prefix_len {
88        if is_sep(prefix[i]) {
89            out.extend_from_slice(b"../");
90            while i < prefix_len && is_sep(prefix[i]) {
91                i += 1;
92            }
93            continue;
94        }
95        i += 1;
96    }
97    if !is_sep(prefix[prefix_len - 1]) {
98        out.extend_from_slice(b"../");
99    }
100    out.extend_from_slice(input);
101    out
102}
103
104// ---------------------------------------------------------------------------
105// Lexical normalization
106// ---------------------------------------------------------------------------
107
108/// Lexically normalize a path (collapse `.`/`..`, no filesystem access).
109///
110/// SAFEST documented semantics, chosen deliberately over the drifting copies
111/// this replaces: leading `..` components are *retained* rather than silently
112/// dropped (`a/../../b` normalizes to `../b`, never `b`), so an input that
113/// escapes its base stays visibly escapable instead of being quietly rewritten
114/// into a different path. Mirrors the component cleanup git's
115/// `strbuf_realpath_forgiving` performs on the parts of a path that do not
116/// exist on disk.
117pub fn normalize_lexical(path: &Path) -> PathBuf {
118    let mut out = PathBuf::new();
119    for component in path.components() {
120        match component {
121            Component::ParentDir => {
122                if !out.pop() {
123                    out.push("..");
124                }
125            }
126            Component::CurDir => {}
127            other => out.push(other.as_os_str()),
128        }
129    }
130    out
131}
132
133/// Compute `target` expressed relative to `base` as a slash-separated string,
134/// purely lexically (both sides normalized with [`normalize_lexical`], no
135/// filesystem access). Returns `"."` when the two paths coincide.
136pub fn relative_path_lexical(target: &Path, base: &Path) -> String {
137    let target = normalize_lexical(target);
138    let base = normalize_lexical(base);
139    let target_components: Vec<_> = target.components().collect();
140    let base_components: Vec<_> = base.components().collect();
141    let common = target_components
142        .iter()
143        .zip(base_components.iter())
144        .take_while(|(a, b)| a == b)
145        .count();
146    let mut result = PathBuf::new();
147    for _ in common..base_components.len() {
148        result.push("..");
149    }
150    for component in &target_components[common..] {
151        result.push(component.as_os_str());
152    }
153    if result.as_os_str().is_empty() {
154        ".".to_string()
155    } else {
156        result.display().to_string()
157    }
158}
159
160/// Compute `target` expressed relative to the directory `cwd`, both expected to
161/// be absolute. Like git's `relative_path()` common-ancestor case: emits
162/// `../` per unshared `cwd` component, then the unshared tail of `target`,
163/// with a trailing `/` when the result names `cwd` itself.
164///
165/// When the two share no root component (e.g. different DOS drives), `target`
166/// is returned unchanged (rendered lossily) — there is no meaningful relative
167/// spelling across roots.
168pub fn relative_path_from_absolute(cwd: &Path, target: &Path) -> Result<String> {
169    let cwd = fs::canonicalize(cwd).map_err(|err| GitError::Io(err.to_string()))?;
170    relative_path_from_absolute_components(&cwd, target)
171}
172
173/// Component-math core of [`relative_path_from_absolute`] with no filesystem
174/// access: callers that already hold canonicalized/real paths use this directly.
175pub fn relative_path_from_absolute_components(cwd: &Path, target: &Path) -> Result<String> {
176    let cwd_components = cwd.components().collect::<Vec<_>>();
177    let target_components = target.components().collect::<Vec<_>>();
178    let common = cwd_components
179        .iter()
180        .zip(target_components.iter())
181        .take_while(|(left, right)| left == right)
182        .count();
183    if common == 0 {
184        return Ok(target.display().to_string());
185    }
186
187    let up_count = cwd_components.len().saturating_sub(common);
188    let mut parts = Vec::new();
189    parts.extend((0..up_count).map(|_| "..".to_string()));
190    parts.extend(
191        target_components[common..]
192            .iter()
193            .map(|component| component.as_os_str().to_string_lossy().into_owned()),
194    );
195    if parts.is_empty() {
196        return Ok("./".into());
197    }
198    let mut relative = parts.join("/");
199    if common == target_components.len() {
200        relative.push('/');
201    }
202    Ok(relative)
203}
204
205/// Compute `to_path` expressed relative to the directory `from_dir`, both
206/// expected to be absolute, returning a [`PathBuf`].
207///
208/// Edge semantics pinned by callers (`git worktree move/remove` link rewriting):
209/// when the two share no root component, `to_path` (normalized) is returned
210/// verbatim, and when the two coincide the result is `.`.
211pub fn relative_path_between(from_dir: &Path, to_path: &Path) -> PathBuf {
212    let from = normalize_lexical(from_dir);
213    let to = normalize_lexical(to_path);
214    let from_components = from.components().collect::<Vec<_>>();
215    let to_components = to.components().collect::<Vec<_>>();
216    let mut common = 0usize;
217    while common < from_components.len()
218        && common < to_components.len()
219        && from_components[common] == to_components[common]
220    {
221        common += 1;
222    }
223    if common == 0 {
224        return to;
225    }
226    let mut relative = PathBuf::new();
227    for component in &from_components[common..] {
228        if matches!(component, Component::Normal(_)) {
229            relative.push("..");
230        }
231    }
232    for component in &to_components[common..] {
233        match component {
234            Component::Normal(value) => relative.push(value),
235            Component::ParentDir => relative.push(".."),
236            Component::CurDir | Component::RootDir | Component::Prefix(_) => {}
237        }
238    }
239    if relative.as_os_str().is_empty() {
240        relative.push(".");
241    }
242    relative
243}
244
245// ---------------------------------------------------------------------------
246// Bytes ↔ path conversions
247// ---------------------------------------------------------------------------
248
249/// A path/`OsStr`'s byte encoding. On Unix these are the OS-native bytes; off
250/// Unix they are decoded lossily as UTF-8 with `\` normalized to `/`, matching
251/// git's forward-slash path convention.
252pub fn os_str_to_bytes(value: &OsStr) -> Vec<u8> {
253    #[cfg(unix)]
254    {
255        use std::os::unix::ffi::OsStrExt;
256        value.as_bytes().to_vec()
257    }
258    #[cfg(not(unix))]
259    {
260        value.to_string_lossy().replace('\\', "/").into_bytes()
261    }
262}
263
264/// Convenience wrapper over [`os_str_to_bytes`] taking a whole path.
265pub fn path_to_bytes(path: &Path) -> Vec<u8> {
266    os_str_to_bytes(path.as_os_str())
267}
268
269/// Interpret raw path bytes as a (relative) [`PathBuf`]. On Unix the bytes are
270/// the OS-native path encoding; off Unix they are decoded lossily as UTF-8.
271pub fn bytes_to_os_path(bytes: &[u8]) -> PathBuf {
272    #[cfg(unix)]
273    {
274        use std::os::unix::ffi::OsStrExt;
275        PathBuf::from(OsStr::from_bytes(bytes))
276    }
277    #[cfg(not(unix))]
278    {
279        PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
280    }
281}
282
283/// Interpret raw path bytes as a UTF-8 `String`, failing rather than guessing:
284/// git stores paths as opaque bytes, and callers that must echo them back into
285/// string-shaped data structures refuse non-UTF-8 input explicitly.
286pub fn bytes_to_path_string(bytes: &[u8]) -> Result<String> {
287    std::str::from_utf8(bytes)
288        .map(str::to_string)
289        .map_err(|_| GitError::InvalidFormat("non-utf8 worktree path".into()))
290}
291
292/// Render a path with forward slashes (git uses `/` in trace prefixes and
293/// on-wire path fields): the join of its normal components, dropping any root.
294pub fn path_to_slash(path: &Path) -> String {
295    path.components()
296        .filter_map(|component| match component {
297            Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
298            _ => None,
299        })
300        .collect::<Vec<_>>()
301        .join("/")
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    // Oracle-derived expectations: upstream git 2.55 renders ls-files/diff
309    // paths from a subdirectory through `relative_path()` (path.c) with the
310    // cwd prefix (`git rev-parse --show-prefix`) always ending in '/'.
311    #[test]
312    fn relative_path_bytes_matches_git_relative_path() {
313        // No prefix: input passes through untouched.
314        assert_eq!(relative_path_bytes(b"a/b/c.txt", b""), b"a/b/c.txt");
315        // Empty input: git returns "./".
316        assert_eq!(relative_path_bytes(b"", b"a/b/"), b"./");
317        // Fully shared prefix: bare tail.
318        assert_eq!(relative_path_bytes(b"a/b/c.txt", b"a/b/"), b"c.txt");
319        // Input equals the prefix directory itself.
320        assert_eq!(relative_path_bytes(b"a/b/", b"a/b/"), b"./");
321        // One shared level: single ../.
322        assert_eq!(relative_path_bytes(b"a/x.txt", b"a/b/"), b"../x.txt");
323        // Sibling subtree several levels up: ../.. per unshared prefix level.
324        assert_eq!(
325            relative_path_bytes(b"sib/out.txt", b"a/b/c/"),
326            b"../../../sib/out.txt"
327        );
328        // Root file viewed from deep inside.
329        assert_eq!(relative_path_bytes(b"root.txt", b"a/b/c/"), b"../../../root.txt");
330        // Deeper tail under the prefix keeps inner directories.
331        assert_eq!(relative_path_bytes(b"a/b/c/d/e.txt", b"a/b/c/"), b"d/e.txt");
332        // Prefix without trailing slash still terminates one level short
333        // (git's cmd_prefix always supplies '/', but path.c tolerates both).
334        assert_eq!(relative_path_bytes(b"a/top.txt", b"a/b"), b"../top.txt");
335        // Divergent components at the same depth are NOT shared: 's' != 't'.
336        assert_eq!(
337            relative_path_bytes(b"t/c.txt", b"same/"),
338            b"../t/c.txt"
339        );
340    }
341
342    #[test]
343    fn normalize_lexical_retains_leading_dotdot_and_drops_curdir() {
344        assert_eq!(normalize_lexical(Path::new("a/b/../c")), PathBuf::from("a/c"));
345        assert_eq!(normalize_lexical(Path::new("./a/./b")), PathBuf::from("a/b"));
346        // Leading .. must survive: the path genuinely escapes its base.
347        assert_eq!(normalize_lexical(Path::new("../b")), PathBuf::from("../b"));
348        assert_eq!(
349            normalize_lexical(Path::new("a/../../b")),
350            PathBuf::from("../b")
351        );
352        // Ascending past an absolute root cannot go further: git's forgiving
353        // realpath keeps the residual `..` visible rather than clamping.
354        assert_eq!(normalize_lexical(Path::new("/..")), PathBuf::from("/.."));
355        assert_eq!(
356            normalize_lexical(Path::new("/a/../../c")),
357            PathBuf::from("/../c")
358        );
359        assert_eq!(normalize_lexical(Path::new("")), PathBuf::from(""));
360    }
361
362    #[test]
363    fn relative_path_lexical_handles_sibling_worktree_layouts() {
364        // Main admin dir and linked-worktree .git under one parent.
365        let admin = Path::new("/repo/.git");
366        let wt = Path::new("/repo/wt/.git");
367        assert_eq!(relative_path_lexical(wt, admin), "../wt/.git");
368        assert_eq!(relative_path_lexical(admin, wt), "../../.git");
369        assert_eq!(relative_path_lexical(admin, admin), ".");
370    }
371
372    #[test]
373    fn relative_path_from_absolute_components_pins_edges() {
374        // Same directory: "./" (trailing slash marks the directory itself).
375        assert_eq!(
376            relative_path_from_absolute_components(Path::new("/r/wt"), Path::new("/r/wt"))
377                .unwrap_or_default(),
378            "./"
379        );
380        // Descendant: plain tail.
381        assert_eq!(
382            relative_path_from_absolute_components(Path::new("/r/wt"), Path::new("/r/wt/a/b"))
383                .unwrap_or_default(),
384            "a/b"
385        );
386        // Ancestor: ../ chain.
387        assert_eq!(
388            relative_path_from_absolute_components(Path::new("/r/wt/sub"), Path::new("/r/.git"))
389                .unwrap_or_default(),
390            "../../.git"
391        );
392        // Sharing only the root still walks up one level (POSIX): /a → /b/c.
393        assert_eq!(
394            relative_path_from_absolute_components(Path::new("/a"), Path::new("/b/c"))
395                .unwrap_or_default(),
396            "../b/c"
397        );
398        // Truly disjoint roots (no common prefix component at all — different
399        // DOS drives, or an empty cwd side): target verbatim.
400        assert_eq!(
401            relative_path_from_absolute_components(Path::new(""), Path::new("/b/c"))
402                .unwrap_or_default(),
403            "/b/c"
404        );
405    }
406
407    #[test]
408    fn relative_path_between_keeps_move_remove_edge_semantics() {
409        // Same dir yields "." (not "./") for link-file rewriting.
410        assert_eq!(
411            relative_path_between(Path::new("/r/.git"), Path::new("/r/.git")),
412            PathBuf::from(".")
413        );
414        assert_eq!(
415            relative_path_between(Path::new("/r/.git"), Path::new("/r/wt/.git")),
416            PathBuf::from("../wt/.git")
417        );
418        assert_eq!(
419            relative_path_between(Path::new("/r/wt/.git"), Path::new("/r/.git")),
420            PathBuf::from("../../.git")
421        );
422        // Lexical normalization applies before the walk.
423        assert_eq!(
424            relative_path_between(
425                Path::new("/r/wt/../.git"),
426                Path::new("/r/linked/../wt2/.git")
427            ),
428            PathBuf::from("../wt2/.git")
429        );
430    }
431
432    #[test]
433    fn byte_conversions_round_trip_and_slash_normalize() {
434        let weird = bytes_to_os_path(b"\xff\xfe/weird.txt");
435        assert_eq!(path_to_bytes(&weird), b"\xff\xfe/weird.txt");
436        assert_eq!(os_str_to_bytes(OsStr::new("plain/path")), b"plain/path");
437        assert_eq!(path_to_slash(Path::new("/a/b/c")), "a/b/c");
438        assert_eq!(path_to_slash(Path::new("a/b")), "a/b");
439        assert_eq!(bytes_to_path_string(b"ok.txt").ok(), Some("ok.txt".to_string()));
440        assert!(bytes_to_path_string(b"\xff").is_err());
441    }
442}