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}