Skip to main content

git_sprout/
plan.rs

1// ABOUTME: Turns two tree listings and the source index into the set of paths to clone.
2// ABOUTME: Nothing enters the plan that has not passed every check in `verify`.
3
4use std::collections::{BTreeMap, HashMap, HashSet};
5use std::path::{Path, PathBuf};
6
7use gix_hash::ObjectId;
8
9use crate::tree::{directory_of, Listing};
10use crate::verify::{self, Verdict};
11
12/// A path the plan intends to materialise by cloning.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Planned {
15    pub path: Vec<u8>,
16    /// The mode from the tree. Never the mode the source file happens to carry.
17    pub mode: u32,
18    pub oid: ObjectId,
19    /// The source file's modification time, as the source index records it.
20    pub mtime: gix_index::entry::stat::Time,
21}
22
23/// What the verification pass concluded about the target tree.
24#[derive(Debug, Default)]
25pub struct Verified {
26    /// Paths whose source file may stand in for a checkout.
27    pub paths: Vec<Planned>,
28    /// Paths whose stat data fell in the racily-clean window, for git to settle.
29    pub racy: Vec<Planned>,
30    /// How many blobs the target tree holds in total.
31    pub considered: usize,
32}
33
34/// The work to do, once the racy paths have been settled and directories chosen.
35#[derive(Debug, Default)]
36pub struct Plan {
37    /// Subtrees to clone in a single call, topmost only, in tree order.
38    pub directories: Vec<Vec<u8>>,
39    /// Every directory those clones put on disk, roots included. A clone copies the
40    /// source's permissions, so each of these needs the ones a checkout would have given
41    /// it.
42    pub directories_created: Vec<Vec<u8>>,
43    /// Paths to clone one at a time, because no chosen directory covers them.
44    pub files: Vec<Planned>,
45    /// Every path the plan materialises, including those inside a cloned directory.
46    pub materialised: Vec<Planned>,
47}
48
49/// The paths and subtrees a filesystem that folds case cannot keep apart.
50///
51/// Where two tracked paths differ only by case, only one file can exist, and real
52/// `git worktree add` settles which by checking every entry out in index order: the last
53/// one written unlinks and recreates the shared file, so it decides both the name on disk
54/// and the content, and the other path is reported modified. Cloning even one member wins
55/// a collision git would have lost, which spec §3.5.1 forbids, and inverts the dirty set.
56/// So a whole group goes to git, which settles it exactly as it always would.
57///
58/// The fold is ASCII, which is what git's own case-insensitive comparisons use. A
59/// filesystem that also folds beyond ASCII, as APFS does, can still collide on paths this
60/// leaves in the plan; the clone of the second one fails and demotes the run, which is
61/// slow rather than wrong.
62///
63/// On a case-sensitive filesystem this costs a handful of paths and changes nothing else.
64pub fn colliding_paths(
65    target: &Listing,
66    source_paths: &[Vec<u8>],
67) -> (HashSet<Vec<u8>>, Vec<Vec<u8>>) {
68    // Keyed on the folded name, holding the distinct spellings seen under it. A set
69    // rather than a list because the same path arrives from both the target tree and
70    // the source index, and counting one path twice would make every path in the
71    // repository look like a collision with itself.
72    let mut by_folded: HashMap<Vec<u8>, HashSet<Vec<u8>>> = HashMap::new();
73    // Folded over the source's paths as well as the target's, because the hazard lives
74    // in the source's filesystem rather than in either tree. Where two paths differ only
75    // by case the operating system keeps one file for both names, and the bytes behind
76    // the surviving name may belong to either member. A pair that exists at the source's
77    // HEAD but not in the target — a detached checkout, a tag, any commit where one
78    // member was added or removed — is invisible to the target alone, and the surviving
79    // path then looks like an ordinary clonable file whose content is somebody else's.
80    //
81    // Widening costs a handful of clones that would have been safe. The alternative
82    // costs a worktree that differs from git's.
83    let named = target
84        .blobs
85        .keys()
86        .chain(target.trees.keys())
87        .chain(target.gitlinks.iter())
88        .chain(source_paths.iter());
89    for path in named {
90        by_folded
91            .entry(path.to_ascii_lowercase())
92            .or_default()
93            .insert(path.clone());
94    }
95
96    let mut paths = HashSet::new();
97    let mut prefixes = Vec::new();
98    for (_, group) in by_folded.into_iter().filter(|(_, group)| group.len() > 1) {
99        for path in group {
100            // A source-only path is not in the plan to begin with; naming it here would
101            // be harmless but misleading. What matters is that its *target* twin is.
102            if target.trees.contains_key(&path) {
103                let mut prefix = path.clone();
104                prefix.push(b'/');
105                prefixes.push(prefix);
106            }
107            paths.insert(path);
108        }
109    }
110    prefixes.sort();
111    (paths, prefixes)
112}
113
114/// Applies every check that does not depend on git re-reading a file's content.
115pub fn verify_paths(
116    target: &Listing,
117    source_index: &gix_index::File,
118    source_root: &Path,
119    poisoned: &[Vec<u8>],
120    excluded: &HashSet<Vec<u8>>,
121) -> Verified {
122    let mut verified = Verified {
123        considered: target.blobs.len(),
124        ..Verified::default()
125    };
126    let timestamp = source_index.timestamp();
127
128    for (path, blob) in &target.blobs {
129        if excluded.contains(path) || verify::is_poisoned(path, poisoned) {
130            continue;
131        }
132        let Some(entry) = source_index.entry_by_path(path.as_slice().into()) else {
133            continue;
134        };
135        if !verify::entry_can_stand_in(blob, entry) {
136            continue;
137        }
138        let Ok(metadata) =
139            gix_index::fs::Metadata::from_path_no_follow(&source_root.join(as_path(path)))
140        else {
141            continue;
142        };
143        let planned = Planned {
144            path: path.clone(),
145            mode: blob.mode,
146            oid: blob.oid,
147            mtime: entry.stat.mtime,
148        };
149        match verify::stat_verdict(entry, &metadata, timestamp) {
150            Verdict::Clone => verified.paths.push(planned),
151            Verdict::AskGit => verified.racy.push(planned),
152            Verdict::Reject => {}
153        }
154    }
155    verified
156}
157
158/// Chooses the subtrees worth cloning in one call and splits the rest into single files.
159///
160/// A subtree qualifies only when both sides record the same tree oid, every blob under it
161/// is verified, and the source directory holds exactly the tracked entries and nothing
162/// else. That last condition is what keeps untracked and ignored files out of the new
163/// worktree: a directory clone copies whatever is on disk, so anything on disk that the
164/// tree does not name disqualifies the whole subtree.
165pub fn assemble(
166    target: &Listing,
167    source: &Listing,
168    source_root: &Path,
169    destination: &Path,
170    verified: Vec<Planned>,
171    clone_directories: bool,
172) -> Plan {
173    let mut plan = Plan {
174        materialised: verified,
175        ..Plan::default()
176    };
177    let verified_paths: HashSet<&[u8]> = plan
178        .materialised
179        .iter()
180        .map(|planned| planned.path.as_slice())
181        .collect();
182
183    if clone_directories {
184        let children = children_by_directory(target);
185        let mut covered: Vec<Vec<u8>> = Vec::new();
186        for (path, oid) in &target.trees {
187            if covered.iter().any(|prefix| path.starts_with(prefix)) {
188                continue;
189            }
190            if source.trees.get(path) != Some(oid) {
191                continue;
192            }
193            if !subtree_is_fully_verified(target, path, &verified_paths) {
194                continue;
195            }
196            if destination.join(as_path(path)).exists() {
197                continue;
198            }
199            if !source_holds_only(source_root, path, &children) {
200                continue;
201            }
202            let mut prefix = path.clone();
203            prefix.push(b'/');
204            plan.directories_created.push(path.clone());
205            plan.directories_created.extend(
206                target
207                    .trees
208                    .range(prefix.clone()..)
209                    .take_while(|(under, _)| under.starts_with(&prefix))
210                    .map(|(under, _)| under.clone()),
211            );
212            covered.push(prefix);
213            plan.directories.push(path.clone());
214        }
215        plan.files = plan
216            .materialised
217            .iter()
218            .filter(|planned| {
219                !covered
220                    .iter()
221                    .any(|prefix| planned.path.starts_with(prefix))
222            })
223            .cloned()
224            .collect();
225    } else {
226        plan.files = plan.materialised.clone();
227    }
228
229    plan
230}
231
232/// Every blob under `directory` is verified, there is at least one, and no submodule sits
233/// inside it.
234fn subtree_is_fully_verified(
235    target: &Listing,
236    directory: &[u8],
237    verified: &HashSet<&[u8]>,
238) -> bool {
239    let mut prefix = directory.to_vec();
240    prefix.push(b'/');
241    let mut blobs = 0usize;
242    for (path, _) in target.blobs.range(prefix.clone()..) {
243        if !path.starts_with(&prefix) {
244            break;
245        }
246        if !verified.contains(path.as_slice()) {
247            return false;
248        }
249        blobs += 1;
250    }
251    if blobs == 0 {
252        return false;
253    }
254    if let Some(path) = target.gitlinks.range(prefix.clone()..).next() {
255        if path.starts_with(&prefix) {
256            return false;
257        }
258    }
259    true
260}
261
262/// The immediate children of every directory in the tree, keyed by the directory path.
263fn children_by_directory(listing: &Listing) -> HashMap<Vec<u8>, HashSet<Vec<u8>>> {
264    let mut children: HashMap<Vec<u8>, HashSet<Vec<u8>>> = HashMap::new();
265    let paths = listing
266        .blobs
267        .keys()
268        .chain(listing.trees.keys())
269        .chain(listing.gitlinks.iter());
270    for path in paths {
271        let directory = directory_of(path);
272        let name = path[directory.len()..].to_vec();
273        children
274            .entry(directory.strip_suffix(b"/").unwrap_or(directory).to_vec())
275            .or_default()
276            .insert(name);
277    }
278    children
279}
280
281/// Whether the source directory holds exactly the tracked entries, at every depth.
282fn source_holds_only(
283    source_root: &Path,
284    directory: &[u8],
285    children: &HashMap<Vec<u8>, HashSet<Vec<u8>>>,
286) -> bool {
287    let Some(expected) = children.get(directory) else {
288        return false;
289    };
290    let Ok(entries) = std::fs::read_dir(source_root.join(as_path(directory))) else {
291        return false;
292    };
293    let mut seen: HashSet<Vec<u8>> = HashSet::new();
294    for entry in entries {
295        let Ok(entry) = entry else { return false };
296        seen.insert(file_name_bytes(&entry.file_name()));
297    }
298    if &seen != expected {
299        return false;
300    }
301    for name in expected {
302        let mut child = directory.to_vec();
303        child.push(b'/');
304        child.extend_from_slice(name);
305        if children.contains_key(&child) && !source_holds_only(source_root, &child, children) {
306            return false;
307        }
308    }
309    true
310}
311
312#[cfg(unix)]
313fn file_name_bytes(name: &std::ffi::OsStr) -> Vec<u8> {
314    use std::os::unix::ffi::OsStrExt;
315    name.as_bytes().to_vec()
316}
317
318#[cfg(not(unix))]
319fn file_name_bytes(name: &std::ffi::OsStr) -> Vec<u8> {
320    name.to_string_lossy().into_owned().into_bytes()
321}
322
323/// Turns a repository-relative git path into a filesystem path.
324#[cfg(unix)]
325pub fn as_path(path: &[u8]) -> PathBuf {
326    use std::os::unix::ffi::OsStrExt;
327    PathBuf::from(std::ffi::OsStr::from_bytes(path))
328}
329
330#[cfg(not(unix))]
331pub fn as_path(path: &[u8]) -> PathBuf {
332    PathBuf::from(String::from_utf8_lossy(path).into_owned())
333}
334
335/// The `.gitattributes` blobs the source index records, split into the ones its stat cache
336/// vouches for and the ones only git can settle by re-reading the file.
337pub fn source_attribute_files(
338    source_index: &gix_index::File,
339    source_root: &Path,
340) -> (BTreeMap<Vec<u8>, ObjectId>, Vec<Vec<u8>>) {
341    let mut files = BTreeMap::new();
342    let mut suspect = Vec::new();
343    let timestamp = source_index.timestamp();
344    for entry in source_index.entries() {
345        let path = entry.path(source_index).to_vec();
346        if !crate::tree::is_attributes_file(&path) {
347            continue;
348        }
349        let verdict =
350            gix_index::fs::Metadata::from_path_no_follow(&source_root.join(as_path(&path)))
351                .map(|metadata| verify::stat_verdict(entry, &metadata, timestamp))
352                .unwrap_or(Verdict::Reject);
353        files.insert(path.clone(), entry.id);
354        if verdict != Verdict::Clone {
355            suspect.push(path);
356        }
357    }
358    (files, suspect)
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::tree::Blob;
365
366    fn oid(byte: u8) -> ObjectId {
367        ObjectId::from_hex(format!("{:02x}", byte).repeat(20).as_bytes()).unwrap()
368    }
369
370    fn listing(blobs: &[(&str, u8)], trees: &[(&str, u8)], gitlinks: &[&str]) -> Listing {
371        Listing {
372            blobs: blobs
373                .iter()
374                .map(|(path, byte)| {
375                    (
376                        path.as_bytes().to_vec(),
377                        Blob {
378                            mode: 0o100644,
379                            oid: oid(*byte),
380                        },
381                    )
382                })
383                .collect(),
384            trees: trees
385                .iter()
386                .map(|(path, byte)| (path.as_bytes().to_vec(), oid(*byte)))
387                .collect(),
388            gitlinks: gitlinks
389                .iter()
390                .map(|path| path.as_bytes().to_vec())
391                .collect(),
392        }
393    }
394
395    /// A pair that exists in the source but not in the target is still a collision:
396    /// the source's filesystem keeps one file for both names, so the survivor's bytes
397    /// may belong to either member. Regression for a divergence seen only under
398    /// `--detach` and a tag, where the target resolves to a commit the pair straddles.
399    #[test]
400    fn a_pair_only_the_source_has_still_disqualifies_the_target_twin() {
401        let listing = listing(&[("net/xt_mark.h", 0)], &[], &[]);
402        let (bare, _) = colliding_paths(&listing, &[]);
403        assert!(
404            bare.is_empty(),
405            "the target alone names no pair, so nothing collides"
406        );
407
408        let source = vec![b"net/xt_mark.h".to_vec(), b"net/XT_MARK.h".to_vec()];
409        let (widened, _) = colliding_paths(&listing, &source);
410        assert!(
411            widened.contains(b"net/xt_mark.h".as_slice()),
412            "the target's member of a pair the source holds must be dropped from the plan"
413        );
414    }
415
416    #[test]
417    fn maps_every_directory_to_its_own_children() {
418        let listing = listing(
419            &[("src/a.txt", 1), ("src/deep/b.txt", 2), ("top.txt", 3)],
420            &[("src", 4), ("src/deep", 5)],
421            &[],
422        );
423        let children = children_by_directory(&listing);
424        assert_eq!(
425            children[b"".as_slice()],
426            HashSet::from([b"src".to_vec(), b"top.txt".to_vec()])
427        );
428        assert_eq!(
429            children[b"src".as_slice()],
430            HashSet::from([b"a.txt".to_vec(), b"deep".to_vec()])
431        );
432    }
433
434    #[test]
435    fn a_subtree_needs_every_blob_verified() {
436        let listing = listing(&[("src/a.txt", 1), ("src/b.txt", 2)], &[("src", 4)], &[]);
437        let all = HashSet::from([b"src/a.txt".as_slice(), b"src/b.txt".as_slice()]);
438        assert!(subtree_is_fully_verified(&listing, b"src", &all));
439        let partial = HashSet::from([b"src/a.txt".as_slice()]);
440        assert!(!subtree_is_fully_verified(&listing, b"src", &partial));
441    }
442
443    #[test]
444    fn a_subtree_with_a_submodule_is_never_cloned_whole() {
445        let listing = listing(&[("src/a.txt", 1)], &[("src", 4)], &["src/vendor"]);
446        let all = HashSet::from([b"src/a.txt".as_slice()]);
447        assert!(!subtree_is_fully_verified(&listing, b"src", &all));
448    }
449
450    #[test]
451    fn an_empty_subtree_is_not_worth_cloning() {
452        let listing = listing(&[], &[("src", 4)], &[]);
453        assert!(!subtree_is_fully_verified(
454            &listing,
455            b"src",
456            &HashSet::new()
457        ));
458    }
459
460    #[test]
461    fn paths_that_differ_only_by_case_all_go_to_git() {
462        let listing = listing(
463            &[
464                ("net/xt_MARK.c", 1),
465                ("net/xt_mark.c", 2),
466                ("net/other.c", 3),
467            ],
468            &[("net", 4)],
469            &[],
470        );
471        let (paths, prefixes) = colliding_paths(&listing, &[]);
472        assert!(paths.contains(b"net/xt_MARK.c".as_slice()));
473        assert!(paths.contains(b"net/xt_mark.c".as_slice()));
474        assert!(!paths.contains(b"net/other.c".as_slice()));
475        assert!(prefixes.is_empty());
476    }
477
478    #[test]
479    fn directories_that_differ_only_by_case_take_their_subtrees_with_them() {
480        let listing = listing(
481            &[("Net/a.c", 1), ("net/b.c", 2)],
482            &[("Net", 3), ("net", 4)],
483            &[],
484        );
485        let (paths, prefixes) = colliding_paths(&listing, &[]);
486        assert!(paths.contains(b"Net".as_slice()));
487        assert_eq!(prefixes, vec![b"Net/".to_vec(), b"net/".to_vec()]);
488    }
489
490    #[test]
491    fn a_tree_and_a_blob_that_fold_together_both_go_to_git() {
492        let listing = listing(&[("Doc", 1), ("doc/a.c", 2)], &[("doc", 3)], &[]);
493        let (paths, prefixes) = colliding_paths(&listing, &[]);
494        assert!(paths.contains(b"Doc".as_slice()));
495        assert!(paths.contains(b"doc".as_slice()));
496        assert_eq!(prefixes, vec![b"doc/".to_vec()]);
497    }
498
499    #[test]
500    fn an_untracked_file_disqualifies_the_directory_clone() {
501        let scratch = std::env::temp_dir().join("git-sprout-plan-test");
502        let _ = std::fs::remove_dir_all(&scratch);
503        std::fs::create_dir_all(scratch.join("src")).unwrap();
504        std::fs::write(scratch.join("src/a.txt"), "a").unwrap();
505        let listing = listing(&[("src/a.txt", 1)], &[("src", 4)], &[]);
506        let children = children_by_directory(&listing);
507        assert!(source_holds_only(&scratch, b"src", &children));
508
509        std::fs::write(scratch.join("src/untracked.log"), "x").unwrap();
510        assert!(!source_holds_only(&scratch, b"src", &children));
511        let _ = std::fs::remove_dir_all(&scratch);
512    }
513}