Skip to main content

git_sprout/
tree.rs

1// ABOUTME: Reads `git ls-tree -r -t -z` into the path maps the clone plan works from.
2// ABOUTME: Paths stay raw bytes because git path names are not required to be UTF-8.
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use gix_hash::ObjectId;
7
8/// A tracked file or symlink in a commit's tree.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Blob {
11    /// The mode recorded in the tree. Never taken from the filesystem.
12    pub mode: u32,
13    pub oid: ObjectId,
14}
15
16/// Everything one commit's tree contains, indexed by path.
17#[derive(Debug, Default, Clone, PartialEq, Eq)]
18pub struct Listing {
19    pub blobs: BTreeMap<Vec<u8>, Blob>,
20    pub trees: BTreeMap<Vec<u8>, ObjectId>,
21    /// Submodule paths. These are never cloned; git materialises them.
22    pub gitlinks: BTreeSet<Vec<u8>>,
23}
24
25/// The name git gives the per-directory attributes file.
26pub const ATTRIBUTES_FILE: &[u8] = b".gitattributes";
27
28impl Listing {
29    /// The `.gitattributes` blobs in this tree, keyed by path.
30    pub fn attribute_files(&self) -> BTreeMap<Vec<u8>, ObjectId> {
31        self.blobs
32            .iter()
33            .filter(|(path, _)| is_attributes_file(path))
34            .map(|(path, blob)| (path.clone(), blob.oid))
35            .collect()
36    }
37}
38
39/// Whether a repository-relative path names a per-directory attributes file.
40pub fn is_attributes_file(path: &[u8]) -> bool {
41    path == ATTRIBUTES_FILE
42        || (path.len() > ATTRIBUTES_FILE.len()
43            && path.ends_with(ATTRIBUTES_FILE)
44            && path[path.len() - ATTRIBUTES_FILE.len() - 1] == b'/')
45}
46
47/// The directory part of a path, including the trailing slash. Empty at the root.
48pub fn directory_of(path: &[u8]) -> &[u8] {
49    match path.iter().rposition(|byte| *byte == b'/') {
50        Some(position) => &path[..position + 1],
51        None => &[],
52    }
53}
54
55/// Parses the NUL-separated records `git ls-tree -r -t -z` writes.
56pub fn parse(output: &[u8]) -> Option<Listing> {
57    let mut listing = Listing::default();
58    for record in output.split(|byte| *byte == 0) {
59        if record.is_empty() {
60            continue;
61        }
62        let tab = record.iter().position(|byte| *byte == b'\t')?;
63        let (meta, path) = record.split_at(tab);
64        let path = &path[1..];
65        let mut fields = meta.split(|byte| *byte == b' ');
66        let mode = std::str::from_utf8(fields.next()?).ok()?;
67        let mode = u32::from_str_radix(mode, 8).ok()?;
68        let kind = fields.next()?;
69        let oid = std::str::from_utf8(fields.next()?).ok()?;
70        let oid = ObjectId::from_hex(oid.as_bytes()).ok()?;
71        match kind {
72            b"blob" => {
73                listing.blobs.insert(path.to_vec(), Blob { mode, oid });
74            }
75            b"tree" => {
76                listing.trees.insert(path.to_vec(), oid);
77            }
78            b"commit" => {
79                listing.gitlinks.insert(path.to_vec());
80            }
81            _ => return None,
82        }
83    }
84    Some(listing)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    fn record(mode: &str, kind: &str, oid: &str, path: &str) -> Vec<u8> {
92        format!("{mode} {kind} {oid}\t{path}\0").into_bytes()
93    }
94
95    const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
96    const B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
97
98    #[test]
99    fn reads_blobs_trees_and_gitlinks() {
100        let mut output = record("040000", "tree", A, "src");
101        output.extend(record("100755", "blob", B, "src/run.sh"));
102        output.extend(record("160000", "commit", A, "vendor/lib"));
103        let listing = parse(&output).expect("parses");
104        assert_eq!(listing.trees.len(), 1);
105        assert_eq!(
106            listing.blobs[b"src/run.sh".as_slice()],
107            Blob {
108                mode: 0o100755,
109                oid: ObjectId::from_hex(B.as_bytes()).unwrap()
110            }
111        );
112        assert!(listing.gitlinks.contains(b"vendor/lib".as_slice()));
113    }
114
115    #[test]
116    fn keeps_paths_that_are_not_utf8() {
117        let mut output = b"100644 blob ".to_vec();
118        output.extend(A.as_bytes());
119        output.extend(b"\ta/\xff\0");
120        let listing = parse(&output).expect("parses");
121        assert!(listing.blobs.contains_key(b"a/\xff".as_slice()));
122    }
123
124    #[test]
125    fn recognises_attributes_files_at_any_depth() {
126        assert!(is_attributes_file(b".gitattributes"));
127        assert!(is_attributes_file(b"src/.gitattributes"));
128        assert!(!is_attributes_file(b"src/not.gitattributes"));
129        assert!(!is_attributes_file(b"gitattributes"));
130    }
131
132    #[test]
133    fn splits_the_directory_from_the_path() {
134        assert_eq!(directory_of(b"a/b/c.txt"), b"a/b/");
135        assert_eq!(directory_of(b"c.txt"), b"");
136    }
137}