Skip to main content

git_policy_cli/
repo_read_diff.rs

1//! What:
2//!  The difference between two sorted lists of tree-like entries (a flattened tree, an index), in Git's raw and
3//!  name-only spellings, and the pathspec forms and filters the wrapper's own queries use.
4//! Why:
5//!  `diff-index --cached`, `diff --cached` and `diff-tree` print exactly these lines for the plain cases;
6//!  every case where Git's output depends on more than the two lists (a file that became a directory, a gitlink,
7//!  a possible rename) returns `None`, so Git answers it.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const changes = diffSorted(oldEntries, newEntries); // undefined: ask Git
12//! const text = rawLines(changes) // ":100644 100644 <old> <new> M\0path\0"
13//! ```
14
15#![cfg(unix)]
16
17use super::repo_read_tree::{FlatEntry, MODE_GITLINK};
18
19/// One changed path.
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct Change {
22    /// The path.
23    pub path: Vec<u8>,
24    /// The old mode, 0 for an added path.
25    pub old_mode: u32,
26    /// The new mode, 0 for a deleted path.
27    pub new_mode: u32,
28    /// The old object name, empty for an added path.
29    pub old_id: Vec<u8>,
30    /// The new object name, empty for a deleted path.
31    pub new_id: Vec<u8>,
32    /// The status letter: `A`, `D`, `M` or `T`.
33    pub status: u8,
34}
35
36/// The file type bits of a mode.
37fn type_of(mode: u32) -> u32 {
38    return mode & 0o170_000;
39}
40
41/// Whether some entry of the sorted `list` lies below the directory `path/`.
42fn has_entry_below(list: &[FlatEntry], path: &[u8]) -> bool {
43    let mut prefix: Vec<u8> = path.to_vec();
44    prefix.push(b'/');
45    let at: usize = list.partition_point(|entry| { return entry.path.as_slice() < prefix.as_slice(); });
46    return list.get(at).is_some_and(|entry| { return entry.path.starts_with(prefix.as_slice()); });
47}
48
49/// What:
50///  The changes from `old` to `new`, both sorted by path;
51///  `None` when a path is a file on one side and a directory on the other or when a gitlink is involved.
52/// Why:
53///   Those are the cases whose ordering or spelling Git decides by more than the lists.
54///
55/// In TS you'd write (pseudocode):
56/// ```ts
57/// function diffSorted(oldList: FlatEntry[], newList: FlatEntry[]): Change[] | undefined;
58/// ```
59pub fn diff_sorted(old: &[FlatEntry], new: &[FlatEntry]) -> Option<Vec<Change>> {
60    let mut changes: Vec<Change> = Vec::new();
61    let (mut left, mut right): (usize, usize) = (0, 0);
62    while left < old.len() || right < new.len() {
63        let order = match (old.get(left), new.get(right)) {
64            (Some(before), Some(after)) => before.path.cmp(&after.path),
65            (Some(_), None) => std::cmp::Ordering::Less,
66            (None, _) => std::cmp::Ordering::Greater,
67        };
68        match order {
69            std::cmp::Ordering::Less => {
70                let gone: &FlatEntry = &old[left];
71                if has_entry_below(new, gone.path.as_slice()) {
72                    return None;
73                }
74                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' });
75                left += 1;
76            }
77            std::cmp::Ordering::Greater => {
78                let added: &FlatEntry = &new[right];
79                if has_entry_below(old, added.path.as_slice()) {
80                    return None;
81                }
82                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' });
83                right += 1;
84            }
85            std::cmp::Ordering::Equal => {
86                let (before, after): (&FlatEntry, &FlatEntry) = (&old[left], &new[right]);
87                if before.mode != after.mode || before.id != after.id {
88                    let status: u8 = if type_of(before.mode) == type_of(after.mode) { b'M' } else { b'T' };
89                    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 });
90                }
91                left += 1;
92                right += 1;
93            }
94        }
95    }
96    if changes.iter().any(|change| { return type_of(change.old_mode) == MODE_GITLINK || type_of(change.new_mode) == MODE_GITLINK; }) {
97        return None;
98    }
99    return Some(changes);
100}
101
102/// What:
103///  The pathspecs a query names: the plain relative form (at the top of the worktree only) or `:(top,literal)path`,
104///  or `:/` for everything.
105/// Why:
106///   A glob or any other magic changes what matches, so those return `None`; matching a plain name is
107///  "the path, or anything below it".
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// type PathFilter = { all: true } | { names: Uint8Array[] };
112/// ```
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct PathFilter {
115    /// The names; empty means every path.
116    names: Vec<Vec<u8>>,
117}
118
119impl PathFilter {
120    /// Read the pathspec arguments; `at_top` says whether the command runs at the top of the worktree.
121    pub fn parse(specs: &[&[u8]], at_top: bool) -> Option<PathFilter> {
122        let mut names: Vec<Vec<u8>> = Vec::new();
123        let mut everything: bool = false;
124        for spec in specs {
125            // `:/` selects everything, but every other spec is still checked: a later exclusion or error is Git's.
126            if *spec == b":/" {
127                everything = true;
128                continue;
129            }
130            let (name, top_ok): (&[u8], bool) = match spec.strip_prefix(b":(top,literal)") {
131                Some(rest) => (rest, true),
132                None => (spec, at_top),
133            };
134            let plain: bool = !name.is_empty()
135                && !name.starts_with(b":")
136                && !name.starts_with(b"/")
137                && !name.ends_with(b"/")
138                && !name.windows(2).any(|pair| { return pair == b"//"; })
139                && !name.split(|byte| { return *byte == b'/'; }).any(|part| { return part == b"." || part == b".."; })
140                && !name.iter().any(|byte| { return matches!(byte, b'*' | b'?' | b'[' | b'\\' | 0); });
141            if !plain || !top_ok {
142                return None;
143            }
144            names.push(name.to_vec());
145        }
146        if everything {
147            names.clear();
148        }
149        return Some(PathFilter { names });
150    }
151
152    /// Whether `path` is named by the filter.
153    pub fn matches(&self, path: &[u8]) -> bool {
154        if self.names.is_empty() {
155            return true;
156        }
157        return self.names.iter().any(|name| {
158            return path == name.as_slice() || (path.starts_with(name.as_slice()) && path.get(name.len()) == Some(&b'/'));
159        });
160    }
161}
162
163/// The statuses `--diff-filter` keeps; `None` keeps all. Only the upper-case letters `A D M T` are understood.
164pub fn parse_diff_filter(text: &str) -> Option<Vec<u8>> {
165    if text.is_empty() || !text.bytes().all(|byte| { return matches!(byte, b'A' | b'D' | b'M' | b'T'); }) {
166        return None;
167    }
168    return Some(text.bytes().collect());
169}
170
171/// Keep the changes the filter and statuses name.
172pub fn select(changes: Vec<Change>, filter: &PathFilter, statuses: Option<&[u8]>) -> Vec<Change> {
173    return changes
174        .into_iter()
175        .filter(|change| { return filter.matches(change.path.as_slice()) && statuses.is_none_or(|kept| { return kept.contains(&change.status); }); })
176        .collect();
177}
178
179/// The `-z` raw lines: `:<old mode> <new mode> <old id> <new id> <status>\0<path>\0`.
180pub fn raw_lines(changes: &[Change], zero_id: &str) -> Vec<u8> {
181    let mut out: Vec<u8> = Vec::new();
182    for change in changes {
183        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()) };
184        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()) };
185        out.extend_from_slice(format!(":{:06o} {:06o} {old_id} {new_id} {}", change.old_mode, change.new_mode, char::from(change.status)).as_bytes());
186        out.push(0);
187        out.extend_from_slice(change.path.as_slice());
188        out.push(0);
189    }
190    return out;
191}
192
193/// The `-z` name-only lines: `<path>\0`.
194pub fn name_lines(changes: &[Change]) -> Vec<u8> {
195    let mut out: Vec<u8> = Vec::new();
196    for change in changes {
197        out.extend_from_slice(change.path.as_slice());
198        out.push(0);
199    }
200    return out;
201}
202
203#[cfg(test)]
204#[path = "repo_read_diff_tests.rs"]
205mod tests;