gitoxide_core/repository/index/
entries.rs

1#[derive(Debug)]
2pub struct Options {
3    pub format: crate::OutputFormat,
4    /// If true, also show attributes
5    pub attributes: Option<Attributes>,
6    pub statistics: bool,
7    pub simple: bool,
8    pub recurse_submodules: bool,
9}
10
11#[derive(Debug, Copy, Clone)]
12pub enum Attributes {
13    /// Look at worktree attributes and index as fallback.
14    WorktreeAndIndex,
15    /// Look at attributes from index files only.
16    Index,
17}
18
19pub(crate) mod function {
20    use std::{
21        borrow::Cow,
22        collections::BTreeSet,
23        io::{BufWriter, Write},
24    };
25
26    use gix::{
27        bstr::{BStr, BString},
28        index::entry::Stage,
29        worktree::IndexPersistedOrInMemory,
30        Repository,
31    };
32
33    use crate::{
34        is_dir_to_mode,
35        repository::index::entries::{Attributes, Options},
36        OutputFormat,
37    };
38
39    pub fn entries(
40        repo: gix::Repository,
41        pathspecs: Vec<BString>,
42        out: impl std::io::Write,
43        mut err: impl std::io::Write,
44        Options {
45            simple,
46            format,
47            attributes,
48            statistics,
49            recurse_submodules,
50        }: Options,
51    ) -> anyhow::Result<()> {
52        let mut out = BufWriter::with_capacity(64 * 1024, out);
53        let mut all_attrs = statistics.then(BTreeSet::new);
54
55        #[cfg(feature = "serde")]
56        if let OutputFormat::Json = format {
57            out.write_all(b"[\n")?;
58        }
59
60        let stats = print_entries(
61            &repo,
62            attributes,
63            pathspecs.iter(),
64            format,
65            all_attrs.as_mut(),
66            simple,
67            "".into(),
68            recurse_submodules,
69            &mut out,
70        )?;
71
72        #[cfg(feature = "serde")]
73        if format == OutputFormat::Json {
74            out.write_all(b"]\n")?;
75            out.flush()?;
76            if statistics {
77                serde_json::to_writer_pretty(&mut err, &stats)?;
78            }
79        }
80        if format == OutputFormat::Human && statistics {
81            out.flush()?;
82            writeln!(err, "{stats:#?}")?;
83            if let Some(attrs) = all_attrs.filter(|a| !a.is_empty()) {
84                writeln!(err, "All encountered attributes:")?;
85                for attr in attrs {
86                    writeln!(err, "\t{attr}", attr = attr.as_ref())?;
87                }
88            }
89        }
90        Ok(())
91    }
92
93    #[allow(clippy::too_many_arguments)]
94    fn print_entries(
95        repo: &Repository,
96        attributes: Option<Attributes>,
97        pathspecs: impl IntoIterator<Item = impl AsRef<BStr>> + Clone,
98        format: OutputFormat,
99        mut all_attrs: Option<&mut BTreeSet<gix::attrs::Assignment>>,
100        simple: bool,
101        prefix: &BStr,
102        recurse_submodules: bool,
103        out: &mut impl std::io::Write,
104    ) -> anyhow::Result<Statistics> {
105        let _span = gix::trace::coarse!("print_entries()", git_dir = ?repo.git_dir());
106        let (mut pathspec, index, mut cache) = init_cache(repo, attributes, pathspecs.clone())?;
107        let mut repo_attrs = all_attrs.is_some().then(BTreeSet::default);
108        let submodules_by_path = recurse_submodules
109            .then(|| {
110                repo.submodules()
111                    .map(|opt| {
112                        opt.map(|submodules| {
113                            submodules
114                                .map(|sm| sm.path().map(Cow::into_owned).map(move |path| (path, sm)))
115                                .collect::<Result<Vec<_>, _>>()
116                        })
117                    })
118                    .transpose()
119            })
120            .flatten()
121            .transpose()?
122            .transpose()?;
123        let mut stats = Statistics {
124            entries: index.entries().len(),
125            ..Default::default()
126        };
127        if let Some(entries) = index.prefixed_entries(pathspec.common_prefix()) {
128            stats.entries_after_prune = entries.len();
129            let mut entries = entries.iter().peekable();
130            while let Some(entry) = entries.next() {
131                let mut last_match = None;
132                let attrs = cache
133                    .as_mut()
134                    .and_then(|(attrs, cache)| {
135                        // If the user wants to see assigned attributes, we always have to match.
136                        attributes.is_some().then(|| {
137                            cache.at_entry(entry.path(&index), None).map(|entry| {
138                                let is_excluded = entry.is_excluded();
139                                stats.excluded += usize::from(is_excluded);
140                                let attributes: Vec<_> = {
141                                    last_match = Some(entry.matching_attributes(attrs));
142                                    attrs.iter().map(|m| m.assignment.to_owned()).collect()
143                                };
144                                stats.with_attributes += usize::from(!attributes.is_empty());
145                                stats.max_attributes_per_path = stats.max_attributes_per_path.max(attributes.len());
146                                if let Some(attrs) = repo_attrs.as_mut() {
147                                    attributes.iter().for_each(|attr| {
148                                        attrs.insert(attr.clone());
149                                    });
150                                }
151                                Attrs {
152                                    is_excluded,
153                                    attributes,
154                                }
155                            })
156                        })
157                    })
158                    .transpose()?;
159
160                // Note that we intentionally ignore `_case` so that we act like git does, attribute matching case is determined
161                // by the repository, not the pathspec.
162                let entry_is_excluded = pathspec
163                    .pattern_matching_relative_path(
164                        entry.path(&index),
165                        Some(false),
166                        &mut |rela_path, _case, is_dir, out| {
167                            cache
168                                .as_mut()
169                                .map(|(attrs, cache)| {
170                                    match last_match {
171                                        // The user wants the attributes for display, so the match happened already.
172                                        Some(matched) => {
173                                            attrs.copy_into(cache.attributes_collection(), out);
174                                            matched
175                                        }
176                                        // The user doesn't want attributes, so we set the cache position on demand only
177                                        None => cache
178                                            .at_entry(rela_path, Some(is_dir_to_mode(is_dir)))
179                                            .ok()
180                                            .map(|platform| platform.matching_attributes(out))
181                                            .unwrap_or_default(),
182                                    }
183                                })
184                                .unwrap_or_default()
185                        },
186                    )
187                    .is_none_or(|m| m.is_excluded());
188
189                let entry_is_submodule = entry.mode.is_submodule();
190                if entry_is_excluded && (!entry_is_submodule || !recurse_submodules) {
191                    continue;
192                }
193                if let Some(sm) = submodules_by_path
194                    .as_ref()
195                    .filter(|_| entry_is_submodule)
196                    .and_then(|sms_by_path| {
197                        let entry_path = entry.path(&index);
198                        sms_by_path
199                            .iter()
200                            .find_map(|(path, sm)| (path == entry_path).then_some(sm))
201                            .filter(|sm| sm.git_dir_try_old_form().is_ok_and(|dot_git| dot_git.exists()))
202                    })
203                {
204                    let sm_path = gix::path::to_unix_separators_on_windows(sm.path()?);
205                    let sm_repo = sm.open()?.expect("we checked it exists");
206                    let mut prefix = prefix.to_owned();
207                    prefix.extend_from_slice(sm_path.as_ref());
208                    if !sm_path.ends_with(b"/") {
209                        prefix.push(b'/');
210                    }
211                    let sm_stats = print_entries(
212                        &sm_repo,
213                        attributes,
214                        pathspecs.clone(),
215                        format,
216                        all_attrs.as_deref_mut(),
217                        simple,
218                        prefix.as_ref(),
219                        recurse_submodules,
220                        out,
221                    )?;
222                    stats.submodule.push((sm_path.into_owned(), sm_stats));
223                } else {
224                    match format {
225                        OutputFormat::Human => {
226                            if simple {
227                                to_human_simple(out, &index, entry, attrs, prefix)
228                            } else {
229                                to_human(out, &index, entry, attrs, prefix)
230                            }?;
231                        }
232                        #[cfg(feature = "serde")]
233                        OutputFormat::Json => to_json(out, &index, entry, attrs, entries.peek().is_none(), prefix)?,
234                    }
235                }
236            }
237        }
238
239        stats.cache = cache.map(|c| *c.1.statistics());
240        if let Some((attrs, all_attrs)) = repo_attrs.zip(all_attrs) {
241            stats
242                .attributes
243                .extend(attrs.iter().map(|attr| attr.as_ref().to_string()));
244            all_attrs.extend(attrs);
245        }
246        Ok(stats)
247    }
248
249    #[allow(clippy::type_complexity)]
250    fn init_cache(
251        repo: &Repository,
252        attributes: Option<Attributes>,
253        pathspecs: impl IntoIterator<Item = impl AsRef<BStr>>,
254    ) -> anyhow::Result<(
255        gix::pathspec::Search,
256        IndexPersistedOrInMemory,
257        Option<(gix::attrs::search::Outcome, gix::AttributeStack<'_>)>,
258    )> {
259        let index = repo.index_or_load_from_head()?;
260        let pathspec = repo.pathspec(
261            true,
262            pathspecs,
263            false,
264            &index,
265            gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping.adjust_for_bare(repo.is_bare()),
266        )?;
267        let cache = attributes
268            .or_else(|| {
269                pathspec
270                    .search()
271                    .patterns()
272                    .any(|spec| !spec.attributes.is_empty())
273                    .then_some(Attributes::Index)
274            })
275            .map(|attrs| {
276                repo.attributes(
277                    &index,
278                    match attrs {
279                        Attributes::WorktreeAndIndex => {
280                            gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping
281                                .adjust_for_bare(repo.is_bare())
282                        }
283                        Attributes::Index => gix::worktree::stack::state::attributes::Source::IdMapping,
284                    },
285                    match attrs {
286                        Attributes::WorktreeAndIndex => {
287                            gix::worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped
288                                .adjust_for_bare(repo.is_bare())
289                        }
290                        Attributes::Index => gix::worktree::stack::state::ignore::Source::IdMapping,
291                    },
292                    None,
293                )
294                .map(|cache| (cache.attribute_matches(), cache))
295            })
296            .transpose()?;
297        Ok((pathspec.into_parts().0, index, cache))
298    }
299
300    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
301    struct Attrs {
302        is_excluded: bool,
303        attributes: Vec<gix::attrs::Assignment>,
304    }
305
306    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
307    #[derive(Default, Debug)]
308    struct Statistics {
309        #[allow(dead_code)] // Not really dead, but Debug doesn't count for it even though it's crucial.
310        pub entries: usize,
311        pub entries_after_prune: usize,
312        pub excluded: usize,
313        pub with_attributes: usize,
314        pub max_attributes_per_path: usize,
315        pub cache: Option<gix::worktree::stack::Statistics>,
316        pub attributes: Vec<String>,
317        pub submodule: Vec<(BString, Statistics)>,
318    }
319
320    #[cfg(feature = "serde")]
321    fn to_json(
322        out: &mut impl std::io::Write,
323        index: &gix::index::File,
324        entry: &gix::index::Entry,
325        attrs: Option<Attrs>,
326        is_last: bool,
327        prefix: &BStr,
328    ) -> anyhow::Result<()> {
329        use gix::bstr::ByteSlice;
330        #[derive(serde::Serialize)]
331        struct Entry<'a> {
332            stat: &'a gix::index::entry::Stat,
333            hex_id: String,
334            flags: u32,
335            mode: u32,
336            path: std::borrow::Cow<'a, str>,
337            meta: Option<Attrs>,
338        }
339
340        serde_json::to_writer(
341            &mut *out,
342            &Entry {
343                stat: &entry.stat,
344                hex_id: entry.id.to_hex().to_string(),
345                flags: entry.flags.bits(),
346                mode: entry.mode.bits(),
347                path: if prefix.is_empty() {
348                    entry.path(index).to_str_lossy()
349                } else {
350                    let mut path = prefix.to_owned();
351                    path.extend_from_slice(entry.path(index));
352                    path.to_string().into()
353                },
354                meta: attrs,
355            },
356        )?;
357
358        if is_last {
359            out.write_all(b"\n")?;
360        } else {
361            out.write_all(b",\n")?;
362        }
363        Ok(())
364    }
365
366    fn to_human_simple(
367        out: &mut impl std::io::Write,
368        file: &gix::index::File,
369        entry: &gix::index::Entry,
370        attrs: Option<Attrs>,
371        prefix: &BStr,
372    ) -> std::io::Result<()> {
373        if !prefix.is_empty() {
374            out.write_all(prefix)?;
375        }
376        match attrs {
377            Some(attrs) => {
378                out.write_all(entry.path(file))?;
379                out.write_all(print_attrs(Some(attrs), entry.mode).as_bytes())
380            }
381            None => out.write_all(entry.path(file)),
382        }?;
383        out.write_all(b"\n")
384    }
385
386    fn to_human(
387        out: &mut impl std::io::Write,
388        file: &gix::index::File,
389        entry: &gix::index::Entry,
390        attrs: Option<Attrs>,
391        prefix: &BStr,
392    ) -> std::io::Result<()> {
393        writeln!(
394            out,
395            "{} {}{:?} {} {}{}{}",
396            match entry.flags.stage() {
397                Stage::Unconflicted => "       ",
398                Stage::Base => "BASE   ",
399                Stage::Ours => "OURS   ",
400                Stage::Theirs => "THEIRS ",
401            },
402            if entry.flags.is_empty() {
403                "".to_string()
404            } else {
405                format!("{:?} ", entry.flags)
406            },
407            entry.mode,
408            entry.id,
409            prefix,
410            entry.path(file),
411            print_attrs(attrs, entry.mode)
412        )
413    }
414
415    fn print_attrs(attrs: Option<Attrs>, mode: gix::index::entry::Mode) -> Cow<'static, str> {
416        attrs.map_or(Cow::Borrowed(""), |a| {
417            let mut buf = String::new();
418            if mode.is_sparse() {
419                buf.push_str(" 📁 ");
420            } else if mode.is_submodule() {
421                buf.push_str(" ➡ ");
422            }
423            if a.is_excluded {
424                buf.push_str(" 🗑️");
425            }
426            if !a.attributes.is_empty() {
427                buf.push_str(" (");
428                for assignment in a.attributes {
429                    use std::fmt::Write;
430                    write!(&mut buf, "{}", assignment.as_ref()).ok();
431                    buf.push_str(", ");
432                }
433                buf.pop();
434                buf.pop();
435                buf.push(')');
436            }
437            buf.into()
438        })
439    }
440}