use std::collections::HashSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::git_util::is_safe_git_path;
use crate::path_util::{normalize_to_forward_slashes, path_from_bytes};
pub use crate::index::fsmonitor::{enable_fsmonitor, maybe_print_fsmonitor_tip};
#[cfg(test)]
pub(crate) use crate::index::fsmonitor::{is_fsmonitor_enabled, FSMONITOR_TIP_STAMP};
#[derive(Debug, Clone)]
pub struct ChangeSet {
pub paths: HashSet<PathBuf>,
pub budget_exceeded: Option<usize>,
pub detect_elapsed_ms: u64,
}
#[derive(Debug)]
pub enum FreshnessError {
Io(std::io::Error),
}
impl From<std::io::Error> for FreshnessError {
fn from(err: std::io::Error) -> Self {
FreshnessError::Io(err)
}
}
impl std::fmt::Display for FreshnessError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FreshnessError::Io(e) => write!(f, "git detection error: {e}"),
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum UpdateOutcome {
Updated {
files: usize,
skipped: usize,
detect_elapsed_ms: u64,
},
NoChanges { detect_elapsed_ms: u64 },
BudgetExceeded {
files_behind_estimate: usize,
detect_elapsed_ms: u64,
},
TooManyFiles {
files_behind: usize,
detect_elapsed_ms: u64,
},
OverlayFull {
files_behind: usize,
detect_elapsed_ms: u64,
},
}
impl UpdateOutcome {
pub fn detect_elapsed_ms(&self) -> u64 {
match self {
UpdateOutcome::Updated {
detect_elapsed_ms, ..
}
| UpdateOutcome::NoChanges { detect_elapsed_ms }
| UpdateOutcome::BudgetExceeded {
detect_elapsed_ms, ..
}
| UpdateOutcome::TooManyFiles {
detect_elapsed_ms, ..
}
| UpdateOutcome::OverlayFull {
detect_elapsed_ms, ..
} => *detect_elapsed_ms,
}
}
}
#[derive(Debug, Clone)]
pub struct UpdateLimits {
pub max_files: Option<usize>,
pub budget_ms: Option<u64>,
}
fn parse_nul_paths(bytes: &[u8]) -> Vec<PathBuf> {
bytes
.split(|&b| b == 0)
.map(path_from_bytes)
.filter(|path| is_safe_git_path(path))
.map(normalize_to_forward_slashes)
.collect()
}
pub fn detect_changed_files(
repo_root: &Path,
git: &Path,
budget_ms: Option<u64>,
) -> Result<ChangeSet, FreshnessError> {
let start = Instant::now();
let deadline = budget_ms.map(|ms| start + Duration::from_millis(ms));
let mut changed: HashSet<PathBuf> = HashSet::new();
let commands: [&[&str]; 3] = [
&["diff", "-z", "--name-only", "HEAD"],
&["diff", "-z", "--name-only", "--cached"],
&["ls-files", "-z", "--others", "--exclude-standard"],
];
for args in commands {
if deadline.is_some_and(|d| Instant::now() >= d) {
return Ok(partial(changed, start));
}
match run_git_bounded(git, repo_root, args, deadline)? {
GitOutput::Complete(stdout) => changed.extend(parse_nul_paths(&stdout)),
GitOutput::Partial(stdout) => {
changed.extend(parse_nul_paths(&stdout));
return Ok(partial(changed, start));
}
GitOutput::NoData => {}
}
}
Ok(ChangeSet {
budget_exceeded: None,
detect_elapsed_ms: elapsed_ms(start),
paths: changed,
})
}
fn elapsed_ms(start: Instant) -> u64 {
u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
}
#[derive(Debug)]
pub(super) enum GitOutput {
Complete(Vec<u8>),
Partial(Vec<u8>),
NoData,
}
fn partial(changed: HashSet<PathBuf>, start: Instant) -> ChangeSet {
ChangeSet {
budget_exceeded: Some(changed.len()),
detect_elapsed_ms: elapsed_ms(start),
paths: changed,
}
}
pub(super) fn run_git_bounded(
git: &Path,
repo_root: &Path,
args: &[&str],
deadline: Option<Instant>,
) -> Result<GitOutput, FreshnessError> {
let mut child = Command::new(git)
.arg("-C")
.arg(repo_root)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(FreshnessError::Io)?;
let Some(deadline) = deadline else {
let output = child.wait_with_output().map_err(FreshnessError::Io)?;
return Ok(if output.status.success() {
GitOutput::Complete(output.stdout)
} else {
GitOutput::NoData
});
};
let stdout = child.stdout.take();
let reader = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut out) = stdout {
let _ = out.read_to_end(&mut buf);
}
buf
});
let killed = loop {
match child.try_wait() {
Ok(Some(_)) => break false,
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(2));
}
Ok(None) => {
let _ = child.kill();
break true;
}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
let _ = reader.join();
return Err(FreshnessError::Io(e));
}
}
};
let status = child.wait().map_err(FreshnessError::Io)?;
let buf = reader.join().unwrap_or_default();
if killed {
return Ok(GitOutput::Partial(buf));
}
if !status.success() {
return Ok(GitOutput::NoData);
}
Ok(GitOutput::Complete(buf))
}
#[cfg(test)]
#[path = "freshness_tests.rs"]
mod tests;