Skip to main content

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