Skip to main content

git_sprout/
attributes.rs

1// ABOUTME: Works out which paths a checkout would rewrite on the way to the working tree.
2// ABOUTME: A path whose bytes get converted cannot be judged by the index stat cache alone.
3
4use std::collections::HashMap;
5
6/// The attributes that decide whether checking a blob out rewrites its bytes.
7pub const CONVERTING_ATTRIBUTES: &[&str] =
8    &["text", "eol", "ident", "filter", "working-tree-encoding"];
9
10/// What `git check-attr` reports for one attribute on one path.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Value {
13    Unspecified,
14    Unset,
15    Set,
16    Named(String),
17}
18
19impl Value {
20    fn parse(info: &[u8]) -> Value {
21        match info {
22            b"unspecified" => Value::Unspecified,
23            b"unset" => Value::Unset,
24            b"set" => Value::Set,
25            other => Value::Named(String::from_utf8_lossy(other).into_owned()),
26        }
27    }
28
29    fn applies(&self) -> bool {
30        matches!(self, Value::Set | Value::Named(_))
31    }
32}
33
34/// The line-ending configuration in force for a worktree.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct LineEndings {
37    /// `core.autocrlf` is `true` or `input`; either way checkout and add disagree about
38    /// line endings, so a clean file need not equal what a checkout would write.
39    pub autocrlf: bool,
40    /// `core.eol` asks for CRLF in the working tree.
41    pub crlf: bool,
42}
43
44impl LineEndings {
45    pub fn from_config(autocrlf: Option<&str>, eol: Option<&str>) -> Self {
46        LineEndings {
47            autocrlf: matches!(autocrlf, Some("true") | Some("input")),
48            crlf: matches!(eol, Some("crlf")),
49        }
50    }
51}
52
53/// Whether checking this path out would write something other than the blob's own bytes.
54///
55/// A conversion breaks the rule the clone plan rests on. The index stat cache says the
56/// working-tree file is *clean*, which means the file run back through the clean filter
57/// equals the blob — it does not mean the file equals what a checkout would write. The two
58/// part company whenever the conversion is not reversible: a file with LF endings under
59/// `eol=crlf` is clean and yet a checkout would write CRLF, which git itself warns about
60/// as "LF will be replaced by CRLF the next time Git touches it". Such a path is left to
61/// git, which writes the converted form.
62pub fn converts(values: &HashMap<String, Value>, endings: LineEndings) -> bool {
63    for name in ["ident", "filter", "working-tree-encoding"] {
64        if values.get(name).is_some_and(Value::applies) {
65            return true;
66        }
67    }
68    let text = values.get("text").unwrap_or(&Value::Unspecified);
69    if text == &Value::Unset {
70        return false;
71    }
72    if values.get("eol").is_some_and(Value::applies) {
73        return true;
74    }
75    if text.applies() {
76        return true;
77    }
78    endings.autocrlf || endings.crlf
79}
80
81/// Reads the `<path> NUL <attribute> NUL <value> NUL` triples `git check-attr -z` writes.
82pub fn parse_check_attr(output: &[u8]) -> HashMap<Vec<u8>, HashMap<String, Value>> {
83    let mut fields = output.split(|byte| *byte == 0);
84    let mut paths: HashMap<Vec<u8>, HashMap<String, Value>> = HashMap::new();
85    while let (Some(path), Some(attribute), Some(info)) =
86        (fields.next(), fields.next(), fields.next())
87    {
88        if path.is_empty() {
89            break;
90        }
91        paths.entry(path.to_vec()).or_default().insert(
92            String::from_utf8_lossy(attribute).into_owned(),
93            Value::parse(info),
94        );
95    }
96    paths
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn values(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
104        pairs
105            .iter()
106            .map(|(name, value)| ((*name).to_string(), value.clone()))
107            .collect()
108    }
109
110    const PLAIN: LineEndings = LineEndings {
111        autocrlf: false,
112        crlf: false,
113    };
114
115    #[test]
116    fn a_path_with_no_attributes_is_written_verbatim() {
117        assert!(!converts(&values(&[]), PLAIN));
118    }
119
120    #[test]
121    fn an_eol_attribute_rewrites_the_bytes() {
122        assert!(converts(
123            &values(&[("text", Value::Set), ("eol", Value::Named("crlf".into()))]),
124            PLAIN
125        ));
126    }
127
128    #[test]
129    fn text_alone_rewrites_the_bytes() {
130        assert!(converts(
131            &values(&[("text", Value::Named("auto".into()))]),
132            PLAIN
133        ));
134    }
135
136    #[test]
137    fn a_binary_path_is_never_rewritten() {
138        let binary = values(&[("text", Value::Unset)]);
139        assert!(!converts(&binary, PLAIN));
140        assert!(!converts(
141            &binary,
142            LineEndings {
143                autocrlf: true,
144                crlf: true
145            }
146        ));
147    }
148
149    #[test]
150    fn autocrlf_rewrites_paths_with_no_attributes_of_their_own() {
151        assert!(converts(
152            &values(&[]),
153            LineEndings {
154                autocrlf: true,
155                crlf: false
156            }
157        ));
158    }
159
160    #[test]
161    fn a_filter_rewrites_the_bytes() {
162        assert!(converts(
163            &values(&[("filter", Value::Named("lfs".into()))]),
164            PLAIN
165        ));
166        assert!(converts(&values(&[("ident", Value::Set)]), PLAIN));
167        assert!(converts(
168            &values(&[("working-tree-encoding", Value::Named("UTF-16".into()))]),
169            PLAIN
170        ));
171    }
172
173    #[test]
174    fn reads_the_check_attr_triples() {
175        let output = b"a.txt\0text\0set\0a.txt\0eol\0crlf\0b.bin\0text\0unspecified\0".as_slice();
176        let parsed = parse_check_attr(output);
177        assert_eq!(parsed[b"a.txt".as_slice()]["text"], Value::Set);
178        assert_eq!(
179            parsed[b"a.txt".as_slice()]["eol"],
180            Value::Named("crlf".into())
181        );
182        assert_eq!(parsed[b"b.bin".as_slice()]["text"], Value::Unspecified);
183    }
184
185    #[test]
186    fn reads_line_ending_configuration() {
187        assert!(LineEndings::from_config(Some("true"), None).autocrlf);
188        assert!(LineEndings::from_config(Some("input"), None).autocrlf);
189        assert!(!LineEndings::from_config(Some("false"), None).autocrlf);
190        assert!(LineEndings::from_config(None, Some("crlf")).crlf);
191        assert!(!LineEndings::from_config(None, Some("lf")).crlf);
192    }
193}