[go: up one dir, main page]

git-worktree 0.4.3

A WIP crate of the gitoxide project dedicated implementing everything around working trees and git excludes
Documentation
use std::path::Path;

use bstr::{BStr, BString, ByteSlice};
use git_glob::pattern::Case;
use git_hash::oid;

use crate::fs::{cache::State, PathOidMapping};

type AttributeMatchGroup = git_attributes::MatchGroup<git_attributes::Attributes>;
type IgnoreMatchGroup = git_attributes::MatchGroup<git_attributes::Ignore>;

/// State related to attributes associated with files in the repository.
#[derive(Default, Clone)]
#[allow(unused)]
pub struct Attributes {
    /// Attribute patterns that match the currently set directory (in the stack).
    pub stack: AttributeMatchGroup,
    /// Attribute patterns which aren't tied to the repository root, hence are global. They are consulted last.
    pub globals: AttributeMatchGroup,
}

/// State related to the exclusion of files.
#[derive(Default, Clone)]
#[allow(unused)]
pub struct Ignore {
    /// Ignore patterns passed as overrides to everything else, typically passed on the command-line and the first patterns to
    /// be consulted.
    overrides: IgnoreMatchGroup,
    /// Ignore patterns that match the currently set director (in the stack), which is pushed and popped as needed.
    stack: IgnoreMatchGroup,
    /// Ignore patterns which aren't tied to the repository root, hence are global. They are consulted last.
    globals: IgnoreMatchGroup,
    /// A matching stack of pattern indices which is empty if we have just been initialized to indicate that the
    /// currently set directory had a pattern matched. Note that this one could be negated.
    /// (index into match groups, index into list of pattern lists, index into pattern list)
    matched_directory_patterns_stack: Vec<Option<(usize, usize, usize)>>,
    ///  The name of the file to look for in directories.
    exclude_file_name_for_directories: BString,
    /// The case to use when matching directories as they are pushed onto the stack. We run them against the exclude engine
    /// to know if an entire path can be ignored as a parent directory is ignored.
    case: Case,
}

impl Ignore {
    /// The `exclude_file_name_for_directories` is an optional override for the filename to use when checking per-directory
    /// ignore files within the repository, defaults to`.gitignore`.
    // TODO: more docs
    pub fn new(
        overrides: IgnoreMatchGroup,
        globals: IgnoreMatchGroup,
        exclude_file_name_for_directories: Option<&BStr>,
        case: Case,
    ) -> Self {
        Ignore {
            case,
            overrides,
            globals,
            stack: Default::default(),
            matched_directory_patterns_stack: Vec::with_capacity(6),
            exclude_file_name_for_directories: exclude_file_name_for_directories
                .map(ToOwned::to_owned)
                .unwrap_or_else(|| ".gitignore".into()),
        }
    }
}

impl Ignore {
    pub(crate) fn pop_directory(&mut self) {
        self.matched_directory_patterns_stack.pop().expect("something to pop");
        self.stack.patterns.pop().expect("something to pop");
    }
    /// The match groups from lowest priority to highest.
    pub(crate) fn match_groups(&self) -> [&IgnoreMatchGroup; 3] {
        [&self.globals, &self.stack, &self.overrides]
    }

    pub(crate) fn matching_exclude_pattern(
        &self,
        relative_path: &BStr,
        is_dir: Option<bool>,
        case: Case,
    ) -> Option<git_attributes::Match<'_, ()>> {
        let groups = self.match_groups();
        let mut dir_match = None;
        if let Some((source, mapping)) = self
            .matched_directory_patterns_stack
            .iter()
            .rev()
            .filter_map(|v| *v)
            .map(|(gidx, plidx, pidx)| {
                let list = &groups[gidx].patterns[plidx];
                (list.source.as_deref(), &list.patterns[pidx])
            })
            .next()
        {
            let match_ = git_attributes::Match {
                pattern: &mapping.pattern,
                value: &mapping.value,
                sequence_number: mapping.sequence_number,
                source,
            };
            if mapping.pattern.is_negative() {
                dir_match = Some(match_);
            } else {
                // Note that returning here is wrong if this pattern _was_ preceded by a negative pattern that
                // didn't match the directory, but would match now.
                // Git does it similarly so we do too even though it's incorrect.
                // To fix this, one would probably keep track of whether there was a preceding negative pattern, and
                // if so we check the path in full and only use the dir match if there was no match, similar to the negative
                // case above whose fix fortunately won't change the overall result.
                return match_.into();
            }
        }
        groups
            .iter()
            .rev()
            .find_map(|group| group.pattern_matching_relative_path(relative_path.as_ref(), is_dir, case))
            .or(dir_match)
    }

    /// Like `matching_exclude_pattern()` but without checking if the current directory is excluded.
    /// It returns a triple-index into our data structure from which a match can be reconstructed.
    pub(crate) fn matching_exclude_pattern_no_dir(
        &self,
        relative_path: &BStr,
        is_dir: Option<bool>,
        case: Case,
    ) -> Option<(usize, usize, usize)> {
        let groups = self.match_groups();
        groups.iter().enumerate().rev().find_map(|(gidx, group)| {
            let basename_pos = relative_path.rfind(b"/").map(|p| p + 1);
            group
                .patterns
                .iter()
                .enumerate()
                .rev()
                .find_map(|(plidx, pl)| {
                    pl.pattern_idx_matching_relative_path(relative_path, basename_pos, is_dir, case)
                        .map(|idx| (plidx, idx))
                })
                .map(|(plidx, pidx)| (gidx, plidx, pidx))
        })
    }

    pub(crate) fn push_directory<Find, E>(
        &mut self,
        root: &Path,
        dir: &Path,
        buf: &mut Vec<u8>,
        attribute_files_in_index: &[PathOidMapping<'_>],
        mut find: Find,
    ) -> std::io::Result<()>
    where
        Find: for<'b> FnMut(&oid, &'b mut Vec<u8>) -> Result<git_object::BlobRef<'b>, E>,
        E: std::error::Error + Send + Sync + 'static,
    {
        let rela_dir = dir.strip_prefix(root).expect("dir in root");
        self.matched_directory_patterns_stack
            .push(self.matching_exclude_pattern_no_dir(git_path::into_bstr(rela_dir).as_ref(), Some(true), self.case));

        let ignore_path_relative = rela_dir.join(".gitignore");
        let ignore_path_relative = git_path::to_unix_separators_on_windows(git_path::into_bstr(ignore_path_relative));
        let ignore_file_in_index =
            attribute_files_in_index.binary_search_by(|t| t.0.cmp(ignore_path_relative.as_ref()));
        let follow_symlinks = ignore_file_in_index.is_err();
        if !self
            .stack
            .add_patterns_file(dir.join(".gitignore"), follow_symlinks, Some(root), buf)?
        {
            match ignore_file_in_index {
                Ok(idx) => {
                    let ignore_blob = find(&attribute_files_in_index[idx].1, buf)
                        .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?;
                    let ignore_path = git_path::from_bstring(ignore_path_relative.into_owned());
                    self.stack
                        .add_patterns_buffer(ignore_blob.data, ignore_path, Some(root));
                }
                Err(_) => {
                    // Need one stack level per component so push and pop matches.
                    self.stack.patterns.push(Default::default())
                }
            }
        }
        Ok(())
    }
}

impl Attributes {
    /// Create a new instance from an attribute match group that represents `globals`.
    ///
    /// A stack of attributes will be applied on top of it later.
    pub fn new(globals: AttributeMatchGroup) -> Self {
        Attributes {
            globals,
            stack: Default::default(),
        }
    }
}

impl From<AttributeMatchGroup> for Attributes {
    fn from(group: AttributeMatchGroup) -> Self {
        Attributes::new(group)
    }
}

impl State {
    /// Configure a state to be suitable for checking out files.
    pub fn for_checkout(unlink_on_collision: bool, attributes: Attributes) -> Self {
        State::CreateDirectoryAndAttributesStack {
            unlink_on_collision,
            #[cfg(debug_assertions)]
            test_mkdir_calls: 0,
            attributes,
        }
    }

    /// Configure a state for adding files.
    pub fn for_add(attributes: Attributes, ignore: Ignore) -> Self {
        State::AttributesAndIgnoreStack { attributes, ignore }
    }

    /// Configure a state for status retrieval.
    pub fn for_status(ignore: Ignore) -> Self {
        State::IgnoreStack(ignore)
    }
}

impl State {
    /// Returns a vec of tuples of relative index paths along with the best usable OID for either ignore, attribute files or both.
    ///
    /// - ignores entries which aren't blobs
    /// - ignores ignore entries which are not skip-worktree
    /// - within merges, picks 'our' stage both for ignore and attribute files.
    pub fn build_attribute_list<'paths>(
        &self,
        index: &git_index::State,
        paths: &'paths git_index::PathStorageRef,
        case: Case,
    ) -> Vec<PathOidMapping<'paths>> {
        let a1_backing;
        let a2_backing;
        let names = match self {
            State::IgnoreStack(v) => {
                a1_backing = [(v.exclude_file_name_for_directories.as_bytes().as_bstr(), true)];
                a1_backing.as_ref()
            }
            State::AttributesAndIgnoreStack { ignore, .. } => {
                a2_backing = [
                    (ignore.exclude_file_name_for_directories.as_bytes().as_bstr(), true),
                    (".gitattributes".into(), false),
                ];
                a2_backing.as_ref()
            }
            State::CreateDirectoryAndAttributesStack { .. } => {
                a1_backing = [(".gitattributes".into(), true)];
                a1_backing.as_ref()
            }
        };

        index
            .entries()
            .iter()
            .filter_map(move |entry| {
                let path = entry.path_in(paths);

                // Stage 0 means there is no merge going on, stage 2 means it's 'our' side of the merge, but then
                // there won't be a stage 0.
                if entry.mode == git_index::entry::Mode::FILE && (entry.stage() == 0 || entry.stage() == 2) {
                    let basename = path
                        .rfind_byte(b'/')
                        .map(|pos| path[pos + 1..].as_bstr())
                        .unwrap_or(path);
                    let is_ignore = names.iter().find_map(|t| {
                        match case {
                            Case::Sensitive => basename == t.0,
                            Case::Fold => basename.eq_ignore_ascii_case(t.0),
                        }
                        .then(|| t.1)
                    })?;
                    // See https://github.com/git/git/blob/master/dir.c#L912:L912
                    if is_ignore && !entry.flags.contains(git_index::entry::Flags::SKIP_WORKTREE) {
                        return None;
                    }
                    Some((path, entry.id))
                } else {
                    None
                }
            })
            .collect()
    }

    pub(crate) fn ignore_or_panic(&self) -> &Ignore {
        match self {
            State::IgnoreStack(v) => v,
            State::AttributesAndIgnoreStack { ignore, .. } => ignore,
            State::CreateDirectoryAndAttributesStack { .. } => {
                unreachable!("BUG: must not try to check excludes without it being setup")
            }
        }
    }
}