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