wsb 0.251.6311

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! What git ignores, asked of git.
//!
//! `--gitignore` means: do not spend time on a path git already ignores. The
//! rules that decide it are not simple — nested `.gitignore` files, negations,
//! `.git/info/exclude`, the user's global excludes, and the fact that a
//! TRACKED file is never ignored no matter what a pattern says. Reimplementing
//! that would produce a second, subtly different answer, and the flag would
//! then skip things git does not ignore. So git is asked.
//!
//! This adds a skip and changes nothing else: hidden files, `.git` itself and
//! every existing include/exclude rule behave exactly as they did.

use crate::prelude::*;
use std::collections::HashSet;

/// The set of paths git ignores under one root.
#[derive(Debug, Default, Clone)]
pub struct Ignored {
    /// Absolute paths git reports as ignored. A directory here stands for
    /// everything beneath it.
    entries: HashSet<PathBuf>,
    /// Repositories consulted, for reporting what the answer is based on.
    repositories: Vec<PathBuf>,
}

impl Ignored {
    /// Ask git what it ignores under `root`, including inside nested
    /// repositories.
    ///
    /// A submodule is a `gitlink` to its parent: the parent's `ls-files` stops
    /// at the boundary and says nothing about what is ignored inside it. Each
    /// repository is therefore asked about its own tree, and the search for
    /// them prunes what has already been found ignored — otherwise looking for
    /// nested repositories would walk every `target/` and `node_modules/` the
    /// flag exists to avoid.
    pub fn discover(root: &Path) -> Self {
        let mut found = Self::default();
        let mut pending: Vec<PathBuf> = Vec::new();
        let mut seen: HashSet<PathBuf> = HashSet::new();

        // The repository the root belongs to, which may be above the root: a
        // refactor run inside `src/` is still governed by the rules at the top
        // of that repository.
        if let Ok(owner) = crate::git_backend::repo_root(root) {
            pending.push(owner);
        }

        while let Some(repo) = pending.pop() {
            if !seen.insert(repo.clone()) {
                continue;
            }
            found.absorb(&repo);
            found.repositories.push(repo.clone());
            // Look for nested repositories only under the part of the tree
            // this run cares about, and only where git has not already said
            // "do not look here".
            let search_from = if repo.starts_with(root) { &repo } else { root };
            for nested in found.nested_repositories(search_from, &seen) {
                pending.push(nested);
            }
        }
        found
    }

    /// Merge one repository's ignored paths into the set.
    fn absorb(&mut self, repo: &Path) {
        // `--directory` collapses a wholly-ignored directory into a single
        // entry instead of listing every file inside it, which is the
        // difference between one line and fifty thousand for a `target/`.
        // `-z` because a path may contain anything a filesystem allows,
        // including the newline that would otherwise split it in two.
        let listed = crate::git_backend::git_command()
            .arg("-C")
            .arg(repo)
            .args([
                "ls-files",
                "-z",
                "--others",
                "--ignored",
                "--exclude-standard",
                "--directory",
            ])
            .output();
        let Ok(listed) = listed else {
            return;
        };
        if !listed.status.success() {
            return;
        }
        for raw in listed.stdout.split(|byte| *byte == 0) {
            if raw.is_empty() {
                continue;
            }
            let relative = String::from_utf8_lossy(raw);
            // A directory arrives with a trailing separator. It is dropped so
            // the entry compares equal to the path the walker hands over.
            let trimmed = relative.trim_end_matches('/');
            if trimmed.is_empty() {
                continue;
            }
            self.entries.insert(repo.join(trimmed));
        }
    }

    /// Repositories nested under `from`, skipping ignored subtrees.
    fn nested_repositories(&self, from: &Path, seen: &HashSet<PathBuf>) -> Vec<PathBuf> {
        let mut found = Vec::new();
        let walker = walkdir::WalkDir::new(from)
            .follow_links(false)
            .into_iter()
            .filter_entry(|entry| {
                let path = entry.path();
                if path == from {
                    return true;
                }
                if !entry.file_type().is_dir() {
                    return false;
                }
                // Never descend into a repository's own object store, and
                // never into a subtree git has already disclaimed.
                path.file_name().is_none_or(|name| name != ".git") && !self.contains(path)
            });
        for entry in walker.flatten() {
            let path = entry.path();
            if path == from || !entry.file_type().is_dir() {
                continue;
            }
            // A submodule's `.git` is a FILE pointing at the parent's store,
            // not a directory — testing for existence catches both shapes.
            if path.join(".git").exists() && !seen.contains(path) {
                found.push(path.to_path_buf());
            }
        }
        found
    }

    /// Whether git ignores `path`, or any directory above it.
    pub fn contains(&self, path: &Path) -> bool {
        if self.entries.is_empty() {
            return false;
        }
        let mut current = Some(path);
        while let Some(at) = current {
            if self.entries.contains(at) {
                return true;
            }
            current = at.parent();
        }
        false
    }

    /// Whether anything is ignored at all.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// How many paths git named.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// The repositories this answer came from.
    pub fn repositories(&self) -> &[PathBuf] {
        &self.repositories
    }
}

#[cfg(test)]
#[path = "git_ignored_tests.rs"]
mod git_ignored_tests;