monochromatic-git-policy-cli 0.5.13

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
//! What:
//!  The difference between two sorted lists of tree-like entries (a flattened tree, an index), in Git's raw and
//!  name-only spellings, and the pathspec forms and filters the wrapper's own queries use.
//! Why:
//!  `diff-index --cached`, `diff --cached` and `diff-tree` print exactly these lines for the plain cases;
//!  every case where Git's output depends on more than the two lists (a file that became a directory, a gitlink,
//!  a possible rename) returns `None`, so Git answers it.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const changes = diffSorted(oldEntries, newEntries); // undefined: ask Git
//! const text = rawLines(changes) // ":100644 100644 <old> <new> M\0path\0"
//! ```

#![cfg(unix)]

use super::repo_read_tree::{FlatEntry, MODE_GITLINK};

/// One changed path.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Change {
    /// The path.
    pub path: Vec<u8>,
    /// The old mode, 0 for an added path.
    pub old_mode: u32,
    /// The new mode, 0 for a deleted path.
    pub new_mode: u32,
    /// The old object name, empty for an added path.
    pub old_id: Vec<u8>,
    /// The new object name, empty for a deleted path.
    pub new_id: Vec<u8>,
    /// The status letter: `A`, `D`, `M` or `T`.
    pub status: u8,
}

/// The file type bits of a mode.
fn type_of(mode: u32) -> u32 {
    return mode & 0o170_000;
}

/// Whether some entry of the sorted `list` lies below the directory `path/`.
fn has_entry_below(list: &[FlatEntry], path: &[u8]) -> bool {
    let mut prefix: Vec<u8> = path.to_vec();
    prefix.push(b'/');
    let at: usize = list.partition_point(|entry| { return entry.path.as_slice() < prefix.as_slice(); });
    return list.get(at).is_some_and(|entry| { return entry.path.starts_with(prefix.as_slice()); });
}

/// What:
///  The changes from `old` to `new`, both sorted by path;
///  `None` when a path is a file on one side and a directory on the other or when a gitlink is involved.
/// Why:
///   Those are the cases whose ordering or spelling Git decides by more than the lists.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function diffSorted(oldList: FlatEntry[], newList: FlatEntry[]): Change[] | undefined;
/// ```
pub fn diff_sorted(old: &[FlatEntry], new: &[FlatEntry]) -> Option<Vec<Change>> {
    let mut changes: Vec<Change> = Vec::new();
    let (mut left, mut right): (usize, usize) = (0, 0);
    while left < old.len() || right < new.len() {
        let order = match (old.get(left), new.get(right)) {
            (Some(before), Some(after)) => before.path.cmp(&after.path),
            (Some(_), None) => std::cmp::Ordering::Less,
            (None, _) => std::cmp::Ordering::Greater,
        };
        match order {
            std::cmp::Ordering::Less => {
                let gone: &FlatEntry = &old[left];
                if has_entry_below(new, gone.path.as_slice()) {
                    return None;
                }
                changes.push(Change { path: gone.path.clone(), old_mode: gone.mode, new_mode: 0, old_id: gone.id.clone(), new_id: Vec::new(), status: b'D' });
                left += 1;
            }
            std::cmp::Ordering::Greater => {
                let added: &FlatEntry = &new[right];
                if has_entry_below(old, added.path.as_slice()) {
                    return None;
                }
                changes.push(Change { path: added.path.clone(), old_mode: 0, new_mode: added.mode, old_id: Vec::new(), new_id: added.id.clone(), status: b'A' });
                right += 1;
            }
            std::cmp::Ordering::Equal => {
                let (before, after): (&FlatEntry, &FlatEntry) = (&old[left], &new[right]);
                if before.mode != after.mode || before.id != after.id {
                    let status: u8 = if type_of(before.mode) == type_of(after.mode) { b'M' } else { b'T' };
                    changes.push(Change { path: before.path.clone(), old_mode: before.mode, new_mode: after.mode, old_id: before.id.clone(), new_id: after.id.clone(), status });
                }
                left += 1;
                right += 1;
            }
        }
    }
    if changes.iter().any(|change| { return type_of(change.old_mode) == MODE_GITLINK || type_of(change.new_mode) == MODE_GITLINK; }) {
        return None;
    }
    return Some(changes);
}

/// What:
///  The pathspecs a query names: the plain relative form (at the top of the worktree only) or `:(top,literal)path`,
///  or `:/` for everything.
/// Why:
///   A glob or any other magic changes what matches, so those return `None`; matching a plain name is
///  "the path, or anything below it".
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type PathFilter = { all: true } | { names: Uint8Array[] };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PathFilter {
    /// The names; empty means every path.
    names: Vec<Vec<u8>>,
}

impl PathFilter {
    /// Read the pathspec arguments; `at_top` says whether the command runs at the top of the worktree.
    pub fn parse(specs: &[&[u8]], at_top: bool) -> Option<PathFilter> {
        let mut names: Vec<Vec<u8>> = Vec::new();
        let mut everything: bool = false;
        for spec in specs {
            // `:/` selects everything, but every other spec is still checked: a later exclusion or error is Git's.
            if *spec == b":/" {
                everything = true;
                continue;
            }
            let (name, top_ok): (&[u8], bool) = match spec.strip_prefix(b":(top,literal)") {
                Some(rest) => (rest, true),
                None => (spec, at_top),
            };
            let plain: bool = !name.is_empty()
                && !name.starts_with(b":")
                && !name.starts_with(b"/")
                && !name.ends_with(b"/")
                && !name.windows(2).any(|pair| { return pair == b"//"; })
                && !name.split(|byte| { return *byte == b'/'; }).any(|part| { return part == b"." || part == b".."; })
                && !name.iter().any(|byte| { return matches!(byte, b'*' | b'?' | b'[' | b'\\' | 0); });
            if !plain || !top_ok {
                return None;
            }
            names.push(name.to_vec());
        }
        if everything {
            names.clear();
        }
        return Some(PathFilter { names });
    }

    /// Whether `path` is named by the filter.
    pub fn matches(&self, path: &[u8]) -> bool {
        if self.names.is_empty() {
            return true;
        }
        return self.names.iter().any(|name| {
            return path == name.as_slice() || (path.starts_with(name.as_slice()) && path.get(name.len()) == Some(&b'/'));
        });
    }
}

/// The statuses `--diff-filter` keeps; `None` keeps all. Only the upper-case letters `A D M T` are understood.
pub fn parse_diff_filter(text: &str) -> Option<Vec<u8>> {
    if text.is_empty() || !text.bytes().all(|byte| { return matches!(byte, b'A' | b'D' | b'M' | b'T'); }) {
        return None;
    }
    return Some(text.bytes().collect());
}

/// Keep the changes the filter and statuses name.
pub fn select(changes: Vec<Change>, filter: &PathFilter, statuses: Option<&[u8]>) -> Vec<Change> {
    return changes
        .into_iter()
        .filter(|change| { return filter.matches(change.path.as_slice()) && statuses.is_none_or(|kept| { return kept.contains(&change.status); }); })
        .collect();
}

/// The `-z` raw lines: `:<old mode> <new mode> <old id> <new id> <status>\0<path>\0`.
pub fn raw_lines(changes: &[Change], zero_id: &str) -> Vec<u8> {
    let mut out: Vec<u8> = Vec::new();
    for change in changes {
        let old_id: String = if change.old_id.is_empty() { zero_id.to_string() } else { super::repo_read_object::to_hex(change.old_id.as_slice()) };
        let new_id: String = if change.new_id.is_empty() { zero_id.to_string() } else { super::repo_read_object::to_hex(change.new_id.as_slice()) };
        out.extend_from_slice(format!(":{:06o} {:06o} {old_id} {new_id} {}", change.old_mode, change.new_mode, char::from(change.status)).as_bytes());
        out.push(0);
        out.extend_from_slice(change.path.as_slice());
        out.push(0);
    }
    return out;
}

/// The `-z` name-only lines: `<path>\0`.
pub fn name_lines(changes: &[Change]) -> Vec<u8> {
    let mut out: Vec<u8> = Vec::new();
    for change in changes {
        out.extend_from_slice(change.path.as_slice());
        out.push(0);
    }
    return out;
}

#[cfg(test)]
#[path = "repo_read_diff_tests.rs"]
mod tests;