Skip to main content

git_sprout/
verify.rs

1// ABOUTME: Decides whether a source checkout's file may stand in for a fresh checkout.
2// ABOUTME: The rule is index-based, so working-tree conversions never have to be replayed.
3
4use std::collections::BTreeMap;
5
6use filetime::FileTime;
7use gix_hash::ObjectId;
8use gix_index::entry::stat;
9
10use crate::tree::{directory_of, Blob};
11
12/// What may be done with one path from the target tree.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Verdict {
15    /// The source's bytes are, by git's own definition, what a checkout would write.
16    Clone,
17    /// The stat cache cannot settle it; git must compare the content before we trust it.
18    AskGit,
19    /// Not clonable. Git checks this path out itself.
20    Reject,
21}
22
23/// The stat comparison git performs by default: whole-second mtime and ctime, size,
24/// inode, uid and gid, and no device or nanosecond comparison.
25fn stat_options() -> stat::Options {
26    stat::Options::default()
27}
28
29/// Flags that mean git has stopped maintaining, or stopped trusting, an entry's stat data.
30fn entry_is_untrustworthy(flags: gix_index::entry::Flags) -> bool {
31    use gix_index::entry::Flags;
32    flags.intersects(Flags::ASSUME_VALID | Flags::SKIP_WORKTREE | Flags::INTENT_TO_ADD)
33}
34
35/// Whether a tree mode names something the tool is willing to materialise.
36pub fn mode_is_clonable(mode: u32) -> bool {
37    matches!(mode, 0o100644 | 0o100755 | 0o120000)
38}
39
40/// The checks that need nothing from the filesystem.
41///
42/// See the module documentation on `stat_verdict` for why this is index-based.
43pub fn entry_can_stand_in(target: &Blob, entry: &gix_index::Entry) -> bool {
44    mode_is_clonable(target.mode)
45        && entry.stage() == gix_index::entry::Stage::Unconflicted
46        && !entry_is_untrustworthy(entry.flags)
47        && entry.mode.bits() == target.mode
48        && entry.id == target.oid
49}
50
51/// Judges the source file on disk against the index entry that describes it.
52///
53/// # Why this is index-based and not a hash of the clone
54///
55/// A checked-out file holds the blob *after* `.gitattributes` processing — CRLF
56/// conversion, `ident`, `working-tree-encoding`, clean/smudge filters. Hashing the
57/// source file and comparing it to the target blob oid would therefore reject every
58/// converted path, which on a repo with `core.autocrlf=true` is every text file.
59///
60/// Git's own answer to "is this file an unmodified checkout of this oid?" is the index
61/// stat cache, and it is the answer git acts on for every `status` and every `checkout`.
62/// So if the source's entry is stat-clean and its oid is the blob we want, then the
63/// source's bytes are what a checkout at the destination would write — conversions
64/// included — provided the attributes governing the path are the same on both sides.
65/// That last condition is checked separately, per subtree, by `poisoned_prefixes`.
66///
67/// # How far the stat cache is trusted
68///
69/// Exactly as far as git trusts it, and no further:
70///
71///   * Git compares whole-second mtime and ctime, size, inode, uid and gid. This does
72///     the same, and does it regardless of what `core.checkStat` says, because a repo
73///     configured to check less is a repo whose recorded stat data proves less.
74///   * Entries flagged assume-unchanged, skip-worktree or intent-to-add are rejected
75///     outright by `entry_can_stand_in`: each of those flags is git saying the stat data
76///     does not describe the file on disk.
77///   * Unmerged entries are rejected; they have no single checked-out content.
78///
79/// # The racily-clean window
80///
81/// A file written in the same second the index was written can be modified again inside
82/// that same second and still stat identically, because mtime has one-second resolution
83/// in the index. Git calls such an entry racily clean and re-reads the content instead of
84/// trusting the stat. `Verdict::AskGit` is that case: the caller hands those few paths to
85/// `git diff-files`, which re-reads them with the repository's filters applied and reports
86/// the ones that really differ. Hashing them here would be wrong for exactly the reason
87/// the whole rule is index-based — the working-tree bytes are not the blob bytes.
88///
89/// The window is not narrowed by us and must not be: git decides it, from the mtime it
90/// recorded on the index file itself, and any smaller window would trust files git does
91/// not.
92pub fn stat_verdict(
93    entry: &gix_index::Entry,
94    metadata: &gix_index::fs::Metadata,
95    index_timestamp: FileTime,
96) -> Verdict {
97    if metadata.is_dir() {
98        return Verdict::Reject;
99    }
100    let Ok(on_disk) = gix_index::entry::Stat::from_fs(metadata) else {
101        return Verdict::Reject;
102    };
103    if !on_disk.matches(&entry.stat, stat_options()) {
104        return Verdict::Reject;
105    }
106    if entry.stat.is_racy(index_timestamp, stat_options()) {
107        return Verdict::AskGit;
108    }
109    Verdict::Clone
110}
111
112/// Directory prefixes whose attributes differ between the two sides.
113///
114/// A path may only be cloned when every `.gitattributes` on its ancestry is the same blob
115/// in both commits, because those files decide the conversion applied on checkout. Any
116/// difference — a different blob, present on one side only, or modified in the source
117/// working tree — disqualifies the whole directory it governs and everything below it.
118/// Prefixes include their trailing slash; the empty prefix is the repository root and
119/// therefore disqualifies everything.
120pub fn poisoned_prefixes(
121    target_attributes: &BTreeMap<Vec<u8>, ObjectId>,
122    source_attributes: &BTreeMap<Vec<u8>, ObjectId>,
123    dirty_attributes: &[Vec<u8>],
124) -> Vec<Vec<u8>> {
125    let mut poisoned: Vec<Vec<u8>> = Vec::new();
126    let mut note = |path: &[u8]| poisoned.push(directory_of(path).to_vec());
127
128    for (path, oid) in target_attributes {
129        if source_attributes.get(path) != Some(oid) {
130            note(path);
131        }
132    }
133    for path in source_attributes.keys() {
134        if !target_attributes.contains_key(path) {
135            note(path);
136        }
137    }
138    for path in dirty_attributes {
139        note(path);
140    }
141
142    poisoned.sort();
143    poisoned.dedup();
144    poisoned
145}
146
147/// Whether a path lies under any disqualified directory.
148pub fn is_poisoned(path: &[u8], poisoned: &[Vec<u8>]) -> bool {
149    poisoned
150        .iter()
151        .any(|prefix| prefix.is_empty() || path.starts_with(prefix))
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn oid(byte: u8) -> ObjectId {
159        ObjectId::from_hex(format!("{:02x}", byte).repeat(20).as_bytes()).unwrap()
160    }
161
162    fn attributes(entries: &[(&str, u8)]) -> BTreeMap<Vec<u8>, ObjectId> {
163        entries
164            .iter()
165            .map(|(path, byte)| (path.as_bytes().to_vec(), oid(*byte)))
166            .collect()
167    }
168
169    #[test]
170    fn only_regular_files_and_symlinks_are_clonable() {
171        assert!(mode_is_clonable(0o100644));
172        assert!(mode_is_clonable(0o100755));
173        assert!(mode_is_clonable(0o120000));
174        assert!(!mode_is_clonable(0o160000));
175        assert!(!mode_is_clonable(0o040000));
176    }
177
178    #[test]
179    fn identical_attributes_disqualify_nothing() {
180        let both = attributes(&[(".gitattributes", 1), ("src/.gitattributes", 2)]);
181        assert!(poisoned_prefixes(&both, &both, &[]).is_empty());
182    }
183
184    #[test]
185    fn a_changed_attributes_file_disqualifies_its_directory() {
186        let target = attributes(&[("src/.gitattributes", 1)]);
187        let source = attributes(&[("src/.gitattributes", 2)]);
188        let poisoned = poisoned_prefixes(&target, &source, &[]);
189        assert_eq!(poisoned, vec![b"src/".to_vec()]);
190        assert!(is_poisoned(b"src/a.txt", &poisoned));
191        assert!(!is_poisoned(b"doc/a.txt", &poisoned));
192    }
193
194    #[test]
195    fn a_root_attributes_file_disqualifies_everything() {
196        let target = attributes(&[(".gitattributes", 1)]);
197        let poisoned = poisoned_prefixes(&target, &BTreeMap::new(), &[]);
198        assert!(is_poisoned(b"anything", &poisoned));
199    }
200
201    #[test]
202    fn an_attributes_file_only_the_source_has_disqualifies_its_directory() {
203        let source = attributes(&[("src/.gitattributes", 1)]);
204        let poisoned = poisoned_prefixes(&BTreeMap::new(), &source, &[]);
205        assert!(is_poisoned(b"src/a.txt", &poisoned));
206    }
207
208    #[test]
209    fn a_modified_attributes_file_disqualifies_its_directory() {
210        let both = attributes(&[("src/.gitattributes", 1)]);
211        let poisoned = poisoned_prefixes(&both, &both, &[b"src/.gitattributes".to_vec()]);
212        assert!(is_poisoned(b"src/a.txt", &poisoned));
213    }
214}