grit-lib 0.5.23

Core library for the grit Git implementation
Documentation
//! UTF-8 NFC path normalization for macOS-style filesystems (`core.precomposeUnicode`).
//!
//! When the filesystem treats NFD and NFC spellings as the same path, Git stores paths in
//! precomposed (NFC) form. This module implements the same normalization using ICU.

use icu_normalizer::ComposingNormalizerBorrowed;
use std::borrow::Cow;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};

/// Return true if `s` contains any non-ASCII UTF-8 byte.
#[must_use]
pub fn has_non_ascii_utf8(s: &str) -> bool {
    s.as_bytes().iter().any(|b| *b & 0x80 != 0)
}

/// Normalize a single path segment (no `/`) to NFC when it contains non-ASCII UTF-8.
#[must_use]
pub fn precompose_utf8_segment(s: &str) -> Cow<'_, str> {
    if !has_non_ascii_utf8(s) {
        return Cow::Borrowed(s);
    }
    let normalized = ComposingNormalizerBorrowed::new_nfc().normalize(s);
    if normalized == s {
        Cow::Borrowed(s)
    } else {
        Cow::Owned(normalized.into_owned())
    }
}

/// Normalize every `/`-separated segment of `path` to NFC.
#[must_use]
pub fn precompose_utf8_path(path: &str) -> Cow<'_, str> {
    if !path.as_bytes().iter().any(|b| *b & 0x80 != 0) {
        return Cow::Borrowed(path);
    }
    let mut buf = String::with_capacity(path.len());
    for (i, seg) in path.split('/').enumerate() {
        if i > 0 {
            buf.push('/');
        }
        let c = precompose_utf8_segment(seg);
        buf.push_str(c.as_ref());
    }
    if buf == path {
        Cow::Borrowed(path)
    } else {
        Cow::Owned(buf)
    }
}

/// Probe whether creating a file under `git_dir` with an NFC filename makes the NFD spelling
/// visible as the same path (macOS / HFS+ style).
///
/// Matches Git's `probe_utf8_pathname_composition` / `UTF8_NFD_TO_NFC` test prerequisite.
/// Uses `create_new` like Git's `O_EXCL`: if the probe filename already exists (e.g. copied from
/// an init template), the probe is skipped and the existing file is left untouched.
pub fn probe_filesystem_normalizes_nfd_to_nfc(git_dir: &Path) -> std::io::Result<bool> {
    const NFC: &str = "\u{00e4}";
    const NFD: &str = "\u{0061}\u{0308}";
    let nfc_path: PathBuf = git_dir.join(NFC);
    // Probe tempfile (not a repo lock); exclusive create detects pre-existing paths.
    let mut f = match OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(&nfc_path)
    {
        Ok(f) => f,
        Err(_) => return Ok(false),
    };
    f.write_all(b"x")?;
    let nfd_path = git_dir.join(NFD);
    let aliases = nfd_path.exists();
    let _ = fs::remove_file(&nfc_path);
    Ok(aliases)
}

/// Absolute worktree path and repository-relative index spelling after NFC/NFD resolution.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreePathForStaging {
    /// Path to open on disk (actual directory entry spelling).
    pub abs: PathBuf,
    /// Repository-relative path stored in the index and trees (NFC when precompose is enabled).
    pub index_relpath: String,
}

/// Resolve a repository-relative path to the on-disk file and the index spelling Git uses.
///
/// When `precompose_unicode` is true and `rel` contains non-ASCII bytes, walks path components
/// and matches directory entries by NFC-normalized segment names so an NFD worktree name
/// (common on macOS, or Linux with `GIT_TEST_UTF8_NFD_TO_NFC`) still opens the correct file
/// while the index stores NFC bytes. Otherwise the result is `rel` joined to `work_tree`,
/// without touching the filesystem.
///
/// # Parameters
///
/// - `work_tree` — repository root.
/// - `rel` — path relative to the work tree (may be NFD or NFC).
/// - `precompose_unicode` — whether `core.precomposeunicode` is effectively enabled.
#[must_use]
pub fn resolve_worktree_path_for_staging(
    work_tree: &Path,
    rel: &str,
    precompose_unicode: bool,
) -> WorktreePathForStaging {
    let index_rel = |path: &str| -> String {
        if precompose_unicode {
            precompose_utf8_path(path).into_owned()
        } else {
            path.to_owned()
        }
    };

    // `rel` is a stored path: non-UTF-8 names arrive hex-encoded (`repo_relpath`), so decode
    // it to the on-disk bytes instead of joining the encoded spelling.
    let abs = crate::repo_relpath::worktree_abs(work_tree, rel);
    // An ASCII path is its own NFC and NFD spelling, so no other directory entry can be it.
    if !precompose_unicode || !has_non_ascii_utf8(rel) {
        return WorktreePathForStaging {
            abs,
            index_relpath: rel.to_owned(),
        };
    }
    if fs::symlink_metadata(&abs).is_ok() {
        return WorktreePathForStaging {
            abs,
            index_relpath: index_rel(rel),
        };
    }

    let components: Vec<&str> = rel.split('/').filter(|s| !s.is_empty()).collect();
    if components.is_empty() {
        return WorktreePathForStaging {
            abs,
            index_relpath: index_rel(rel),
        };
    }

    let mut disk_abs = work_tree.to_path_buf();
    let mut disk_rel = String::new();
    for (i, component) in components.iter().enumerate() {
        let want = precompose_utf8_segment(component);
        let direct = disk_abs.join(component);
        if fs::symlink_metadata(&direct).is_ok() {
            disk_abs = direct;
            if !disk_rel.is_empty() {
                disk_rel.push('/');
            }
            disk_rel.push_str(component);
            continue;
        }
        let mut found: Option<String> = None;
        if let Ok(rd) = fs::read_dir(&disk_abs) {
            for ent in rd.flatten() {
                let n = ent.file_name().to_string_lossy().into_owned();
                if precompose_utf8_segment(&n).as_ref() == want.as_ref() {
                    found = Some(n);
                    break;
                }
            }
        }
        let Some(name) = found else {
            return WorktreePathForStaging {
                abs,
                index_relpath: index_rel(rel),
            };
        };
        disk_abs = disk_abs.join(&name);
        if !disk_rel.is_empty() {
            disk_rel.push('/');
        }
        disk_rel.push_str(&name);
        if i + 1 == components.len() {
            return WorktreePathForStaging {
                abs: disk_abs,
                index_relpath: index_rel(&disk_rel),
            };
        }
    }
    WorktreePathForStaging {
        abs: disk_abs,
        index_relpath: index_rel(&disk_rel),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn precompose_nfd_filename_to_nfc() {
        // Matches t3910: Adiarnfc = UTF-8 \303\204 (U+00C4), Adiarnfd = A + U+0308.
        let nfd = "f.A\u{0308}";
        let nfc = "f.\u{00c4}";
        assert_eq!(precompose_utf8_path(nfd).as_ref(), nfc);
    }

    #[test]
    fn resolve_nfc_index_path_for_nfd_on_disk() {
        let td = tempfile::TempDir::new().expect("tempdir");
        let wt = td.path();
        let nfd = "cafe\u{0301}.txt";
        fs::write(wt.join(nfd), b"x").expect("write");
        let nfc = "caf\u{00e9}.txt";
        let r = resolve_worktree_path_for_staging(wt, nfc, true);
        assert!(r.abs.is_file());
        assert_eq!(r.index_relpath.as_str(), nfc);
    }

    #[test]
    fn resolve_ascii_path_is_identity_whether_or_not_it_exists() {
        let td = tempfile::TempDir::new().expect("tempdir");
        let wt = td.path();
        fs::create_dir_all(wt.join("dir")).expect("mkdir");
        fs::write(wt.join("dir/present.txt"), b"x").expect("write");
        for rel in ["dir/present.txt", "dir/missing.txt", "absent/x"] {
            for precompose in [false, true] {
                let r = resolve_worktree_path_for_staging(wt, rel, precompose);
                assert_eq!(r.abs, wt.join(rel), "{rel}");
                assert_eq!(r.index_relpath, rel);
            }
        }
    }

    #[test]
    fn resolve_nfc_path_under_nfd_directory() {
        let td = tempfile::TempDir::new().expect("tempdir");
        let wt = td.path();
        let nfd_dir = "cafe\u{0301}";
        fs::create_dir_all(wt.join(nfd_dir)).expect("mkdir");
        fs::write(wt.join(nfd_dir).join("menu.txt"), b"x").expect("write");
        let r = resolve_worktree_path_for_staging(wt, "caf\u{00e9}/menu.txt", true);
        assert!(r.abs.is_file(), "{}", r.abs.display());
        assert_eq!(r.index_relpath, "caf\u{00e9}/menu.txt");
    }

    #[test]
    fn probe_does_not_remove_existing_template_file() {
        let td = tempfile::TempDir::new().expect("tempdir");
        let git_dir = td.path().join(".git");
        fs::create_dir_all(&git_dir).expect("mkdir");
        const NFC: &str = "\u{00e4}";
        fs::write(git_dir.join(NFC), b"from-template").expect("template file");
        assert!(!probe_filesystem_normalizes_nfd_to_nfc(&git_dir).expect("probe"));
        assert_eq!(fs::read(git_dir.join(NFC)).expect("read"), b"from-template");
    }
}