use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::Command;
use fallow_engine::changed_files::clear_ambient_git_env;
use rustc_hash::FxHashSet;
#[must_use]
#[expect(
clippy::implicit_hasher,
reason = "fallow standardizes on FxHashSet across the workspace"
)]
pub fn can_reuse_current_as_base(
root: &Path,
cache_dir: Option<&Path>,
base_ref: &str,
changed_files: &FxHashSet<PathBuf>,
) -> bool {
let Ok(git_root) = fallow_engine::changed_files::resolve_git_toplevel(root) else {
return false;
};
let canonical_cache_dir = cache_dir.and_then(|dir| dunce::canonicalize(dir).ok());
let mut reader: Option<BaseFileReader> = None;
for path in changed_files {
if cache_dir
.is_some_and(|dir| is_fallow_cache_artifact(path, dir, canonical_cache_dir.as_deref()))
{
continue;
}
if !is_analysis_input(path) {
if is_non_behavioral_doc(path) {
continue;
}
return false;
}
let Ok(current) = std::fs::read_to_string(path) else {
return false;
};
let Ok(relative) = path.strip_prefix(&git_root) else {
return false;
};
let reader = match reader.as_mut() {
Some(reader) => reader,
None => {
let Some(spawned) = BaseFileReader::spawn(root) else {
return false;
};
reader.insert(spawned)
}
};
let base = match reader.read(base_ref, relative) {
BaseRead::Content(base) => base,
BaseRead::Missing | BaseRead::Error => return false,
};
if current == base {
continue;
}
if !js_ts_tokens_equivalent(path, ¤t, &base) {
return false;
}
}
true
}
pub struct BaseFileReader {
child: Option<fallow_process::ScopedChild>,
stdin: Option<std::process::ChildStdin>,
stdout: std::io::BufReader<std::process::ChildStdout>,
}
impl BaseFileReader {
#[must_use]
pub fn spawn(root: &Path) -> Option<Self> {
let mut command = Command::new("git");
command
.args(["cat-file", "--batch"])
.current_dir(root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
clear_ambient_git_env(&mut command);
let mut child = fallow_process::ScopedChild::spawn(&mut command).ok()?;
let stdin = child.take_stdin()?;
let stdout = child.take_stdout()?;
Some(Self {
child: Some(child),
stdin: Some(stdin),
stdout: std::io::BufReader::new(stdout),
})
}
pub fn read(&mut self, base_ref: &str, relative: &Path) -> BaseRead {
use std::io::{BufRead, Read};
let relative = relative.to_string_lossy().replace('\\', "/");
if relative.contains('\n') {
return BaseRead::Error;
}
let Some(stdin) = self.stdin.as_mut() else {
return BaseRead::Error;
};
if writeln!(stdin, "{base_ref}:{relative}").is_err() || stdin.flush().is_err() {
return BaseRead::Error;
}
let mut header = String::new();
if !matches!(self.stdout.read_line(&mut header), Ok(n) if n > 0) {
return BaseRead::Error;
}
if header.trim_end().ends_with(" missing") {
return BaseRead::Missing;
}
let Some(size) = header
.trim_end()
.rsplit(' ')
.next()
.and_then(|raw| raw.parse::<usize>().ok())
else {
return BaseRead::Error;
};
let mut buf = vec![0u8; size];
if self.stdout.read_exact(&mut buf).is_err() {
return BaseRead::Error;
}
let mut newline = [0u8; 1];
if self.stdout.read_exact(&mut newline).is_err() {
return BaseRead::Error;
}
BaseRead::Content(String::from_utf8_lossy(&buf).into_owned())
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum BaseRead {
Content(String),
Missing,
Error,
}
impl Drop for BaseFileReader {
fn drop(&mut self) {
self.stdin.take();
if let Some(child) = self.child.take() {
let _ = child.wait();
}
}
}
fn is_fallow_cache_artifact(
path: &Path,
cache_dir: &Path,
canonical_cache_dir: Option<&Path>,
) -> bool {
path.starts_with(cache_dir)
|| canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
}
pub(super) fn is_analysis_input(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some(
"js" | "jsx"
| "ts"
| "tsx"
| "mjs"
| "mts"
| "cjs"
| "cts"
| "vue"
| "svelte"
| "astro"
| "mdx"
| "css"
| "scss"
)
)
}
pub(super) fn is_non_behavioral_doc(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("md" | "markdown" | "txt" | "rst" | "adoc")
)
}
const REUSE_BLOCKING_MARKERS: &[&str] = &[
"fallow-ignore",
"@expected-unused",
"@public",
"@internal",
"@beta",
"@alpha",
"@api",
"import(",
];
fn has_reuse_blocking_marker(source: &str) -> bool {
REUSE_BLOCKING_MARKERS
.iter()
.any(|marker| source.contains(marker))
}
pub(super) fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
if has_reuse_blocking_marker(current) || has_reuse_blocking_marker(base) {
return false;
}
if !matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
) {
return false;
}
fallow_engine::duplicates::source_token_kinds_equivalent(path, current, base, false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_severed_request_pipe_is_an_error() {
let tmp = tempfile::TempDir::new().expect("temp dir should be created");
let mut reader = BaseFileReader::spawn(tmp.path()).expect("reader should spawn");
reader.stdin.take();
assert_eq!(reader.read("HEAD", Path::new("README.md")), BaseRead::Error);
}
}