use crate::engine::flags::GitFlags;
use anyhow::{Context, Result, bail};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
pub(crate) fn command_paths(target: &Path, flags: GitFlags) -> Result<Vec<PathBuf>> {
let metadata = fs::metadata(target).with_context(|| format!("stat {}", target.display()))?;
if metadata.is_file() {
return Ok(vec![target.to_path_buf()]);
}
if !metadata.is_dir() {
bail!(
"search target is not a file or directory: {}",
target.display()
);
}
if let Some(paths) = git_paths(target, flags, None)? {
return Ok(paths);
}
if flags.has_any() {
log::debug!(
"ignoring Git file selection flags outside repository: {}",
target.display()
);
}
let mut paths = Vec::new();
collect_search_paths(target, &mut paths)?;
Ok(paths)
}
pub(crate) fn def_candidate_paths(
target: &Path,
flags: GitFlags,
search_name: &str,
) -> Result<Vec<PathBuf>> {
let filter = (!search_name.is_empty()).then_some(search_name.as_bytes());
if let Some(paths) = git_paths(target, flags, filter)? {
return Ok(paths);
}
command_paths(target, flags)
}
struct GitScope {
repository: git2::Repository,
workdir: PathBuf,
output_root: PathBuf,
scope: PathBuf,
}
fn resolve_git_scope(target: &Path, flags: GitFlags) -> Result<Option<GitScope>> {
let original_target = target;
let Ok(repository) = git2::Repository::discover(target) else {
return Ok(None);
};
flags.validate()?;
let workdir = repository
.workdir()
.context("Git repository has no work tree")?;
let target = target
.canonicalize()
.with_context(|| format!("canonicalize {}", target.display()))?;
let workdir = workdir
.canonicalize()
.with_context(|| format!("canonicalize {}", workdir.display()))?;
let scope = target
.strip_prefix(&workdir)
.with_context(|| format!("{} is outside Git work tree", target.display()))?;
let output_root = output_root_for_scope(original_target, scope)?;
Ok(Some(GitScope {
repository,
workdir,
output_root,
scope: scope.to_path_buf(),
}))
}
fn git_paths(
target: &Path,
flags: GitFlags,
name_filter: Option<&[u8]>,
) -> Result<Option<Vec<PathBuf>>> {
let Some(scope) = resolve_git_scope(target, flags)? else {
return Ok(None);
};
let default_selection = !flags.has_any();
let cached = flags.cached || default_selection;
let others = flags.others || default_selection;
let mut paths = BTreeSet::new();
if cached {
let index = scope.repository.index().context("read Git index")?;
for entry in index.iter() {
let relative = git_path(&entry.path)?;
if !path_is_in_scope(&relative, &scope.scope) {
continue;
}
let full_path = scope.workdir.join(&relative);
if !full_path.is_file() {
continue;
}
insert_if_matches(
&full_path,
scope.output_root.join(&relative),
name_filter,
&mut paths,
);
}
}
if others {
let mut other_paths = BTreeSet::new();
collect_other_paths(
&scope.repository,
&scope.workdir,
&scope.output_root,
&scope.scope,
flags.ignored,
&mut other_paths,
)?;
for path in other_paths {
insert_if_matches(&path, path.clone(), name_filter, &mut paths);
}
}
Ok(Some(paths.into_iter().collect()))
}
fn insert_if_matches(
read_path: &Path,
output_path: PathBuf,
name_filter: Option<&[u8]>,
paths: &mut BTreeSet<PathBuf>,
) {
let Some(name) = name_filter else {
paths.insert(output_path);
return;
};
let Ok(content) = fs::read(read_path) else {
return;
};
if bytes_contain_identifier(&content, name) {
paths.insert(output_path);
}
}
pub(crate) fn bytes_contain_identifier(text: &[u8], identifier: &[u8]) -> bool {
if identifier.is_empty() {
return true;
}
identifier_spans(text, identifier).next().is_some()
}
pub(crate) fn identifier_spans<'a>(
text: &'a [u8],
identifier: &'a [u8],
) -> impl Iterator<Item = usize> + 'a {
IdentifierSpans {
text,
identifier,
inner: memchr::memmem::find_iter(text, identifier),
}
}
struct IdentifierSpans<'a> {
text: &'a [u8],
identifier: &'a [u8],
inner: memchr::memmem::FindIter<'a, 'a>,
}
impl Iterator for IdentifierSpans<'_> {
type Item = usize;
fn next(&mut self) -> Option<Self::Item> {
if self.identifier.is_empty() {
return None;
}
loop {
let byte_index = self.inner.next()?;
let before = byte_index.checked_sub(1).map(|i| self.text[i]);
let after = self.text.get(byte_index + self.identifier.len()).copied();
if !before.is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_')
&& !after.is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_')
{
return Some(byte_index);
}
}
}
}
fn output_root_for_scope(target: &Path, scope: &Path) -> Result<PathBuf> {
let mut output_root = target.to_path_buf();
for _ in scope.components() {
if !output_root.pop() {
bail!("{} is outside Git work tree", target.display());
}
}
Ok(output_root)
}
fn collect_other_paths(
repository: &git2::Repository,
workdir: &Path,
output_root: &Path,
scope: &Path,
ignored: bool,
paths: &mut BTreeSet<PathBuf>,
) -> Result<()> {
let mut options = git2::StatusOptions::new();
options.include_untracked(true).recurse_untracked_dirs(true);
if ignored {
options.include_ignored(true).recurse_ignored_dirs(true);
}
for entry in repository.statuses(Some(&mut options))?.iter() {
let status = entry.status();
let include = status.contains(git2::Status::WT_NEW)
|| (ignored && status.contains(git2::Status::IGNORED));
if !include {
continue;
}
let Some(relative) = entry.path().map(PathBuf::from) else {
continue;
};
insert_scoped_file(workdir, output_root, scope, &relative, paths);
}
Ok(())
}
fn insert_scoped_file(
workdir: &Path,
output_root: &Path,
scope: &Path,
relative: &Path,
paths: &mut BTreeSet<PathBuf>,
) {
if !path_is_in_scope(relative, scope) {
return;
}
let path = workdir.join(relative);
if path.is_file() {
paths.insert(output_root.join(relative));
}
}
fn git_path(path: &[u8]) -> Result<PathBuf> {
let path = std::str::from_utf8(path).context("Git index path is not UTF-8")?;
Ok(PathBuf::from(path))
}
fn path_is_in_scope(path: &Path, scope: &Path) -> bool {
scope.as_os_str().is_empty() || path.starts_with(scope)
}
fn collect_search_paths(directory: &Path, paths: &mut Vec<PathBuf>) -> Result<()> {
let mut entries = fs::read_dir(directory)
.with_context(|| format!("read directory {}", directory.display()))?
.collect::<std::result::Result<Vec<_>, _>>()
.with_context(|| format!("read directory entry from {}", directory.display()))?;
entries.sort_by_key(std::fs::DirEntry::path);
for entry in entries {
let path = entry.path();
let file_type = entry
.file_type()
.with_context(|| format!("read file type for {}", path.display()))?;
let is_readseek = file_type.is_dir() && entry.file_name() == ".readseek";
if is_readseek {
continue;
}
if file_type.is_dir() {
collect_search_paths(&path, paths)?;
} else if file_type.is_file() {
paths.push(path);
}
}
Ok(())
}