use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime};
use fallow_config::AuditConfig;
use fallow_core::git_env::clear_ambient_git_env;
use xxhash_rust::xxh3::xxh3_64;
use crate::report::plural;
use super::{AuditOptions, git_rev_parse, git_toplevel};
pub(super) struct BaseWorktree {
repo_root: PathBuf,
path: PathBuf,
persistent: bool,
}
impl BaseWorktree {
pub(super) fn create(repo_root: &Path, base_ref: &str, base_sha: Option<&str>) -> Option<Self> {
sweep_orphan_audit_worktrees(repo_root);
if let Some(base_sha) = base_sha
&& let Some(worktree) = Self::reuse_or_create(repo_root, base_sha)
{
return Some(worktree);
}
let path = std::env::temp_dir().join(format!(
"fallow-audit-base-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_nanos()
));
let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
let mut command = Command::new("git");
command
.args([
"worktree",
"add",
"--detach",
"--quiet",
guard.path().to_str()?,
base_ref,
])
.current_dir(repo_root);
clear_ambient_git_env(&mut command);
let output = crate::signal::scoped_child::output(&mut command).ok()?;
if !output.status.success() {
return None;
}
guard.defuse();
drop(guard);
let worktree = Self {
repo_root: repo_root.to_path_buf(),
path,
persistent: false,
};
materialize_base_dependency_context(repo_root, worktree.path());
Some(worktree)
}
pub(super) fn reuse_or_create(repo_root: &Path, base_sha: &str) -> Option<Self> {
let path = reusable_audit_worktree_path(repo_root, base_sha);
let _lock = ReusableWorktreeLock::try_acquire(&path)?;
if reusable_audit_worktree_is_ready(repo_root, &path, base_sha) {
let worktree = Self {
repo_root: repo_root.to_path_buf(),
path,
persistent: true,
};
materialize_base_dependency_context(repo_root, worktree.path());
touch_last_used(worktree.path());
return Some(worktree);
}
remove_audit_worktree(repo_root, &path);
let _ = std::fs::remove_dir_all(&path);
let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
let mut command = Command::new("git");
command
.args([
"worktree",
"add",
"--detach",
"--quiet",
guard.path().to_string_lossy().as_ref(),
base_sha,
])
.current_dir(repo_root);
clear_ambient_git_env(&mut command);
let output = crate::signal::scoped_child::output(&mut command).ok()?;
if !output.status.success() {
return None;
}
guard.defuse();
drop(guard);
let worktree = Self {
repo_root: repo_root.to_path_buf(),
path,
persistent: true,
};
materialize_base_dependency_context(repo_root, worktree.path());
touch_last_used(worktree.path());
Some(worktree)
}
pub(super) fn path(&self) -> &Path {
&self.path
}
}
pub(super) struct WorktreeCleanupGuard<'a> {
repo_root: PathBuf,
path: &'a Path,
armed: bool,
}
impl<'a> WorktreeCleanupGuard<'a> {
pub(super) fn new(repo_root: &Path, path: &'a Path) -> Self {
Self {
repo_root: repo_root.to_path_buf(),
path,
armed: true,
}
}
pub(super) fn path(&self) -> &Path {
self.path
}
pub(super) fn defuse(&mut self) {
self.armed = false;
}
}
impl Drop for WorktreeCleanupGuard<'_> {
fn drop(&mut self) {
if self.armed {
remove_audit_worktree(&self.repo_root, self.path);
let _ = std::fs::remove_dir_all(self.path);
}
}
}
pub(super) struct ReusableWorktreeLock {
_file: std::fs::File,
}
impl ReusableWorktreeLock {
pub(super) fn try_acquire(reusable_path: &Path) -> Option<Self> {
let lock_path = reusable_worktree_lock_path(reusable_path);
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.ok()?;
match file.try_lock() {
Ok(()) => Some(Self { _file: file }),
Err(std::fs::TryLockError::WouldBlock) => {
tracing::debug!(
path = %lock_path.display(),
"reusable audit worktree lock contended; falling back to non-reusable worktree",
);
None
}
Err(std::fs::TryLockError::Error(err)) => {
tracing::debug!(
path = %lock_path.display(),
error = %err,
"could not acquire reusable audit worktree lock; falling back to non-reusable worktree",
);
None
}
}
}
}
pub(super) fn reusable_worktree_lock_path(reusable_path: &Path) -> PathBuf {
let mut name = reusable_path
.file_name()
.map(std::ffi::OsString::from)
.unwrap_or_default();
name.push(".lock");
reusable_path
.parent()
.map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
}
const DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS: u32 = 30;
const AUDIT_CACHE_MAX_AGE_ENV: &str = "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS";
const REUSABLE_LAST_USED_SUFFIX: &str = ".last-used";
pub(super) fn reusable_worktree_last_used_path(reusable_path: &Path) -> PathBuf {
let mut name = reusable_path
.file_name()
.map(std::ffi::OsString::from)
.unwrap_or_default();
name.push(REUSABLE_LAST_USED_SUFFIX);
reusable_path
.parent()
.map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
}
pub(super) fn touch_last_used(reusable_path: &Path) {
let last_used = reusable_worktree_last_used_path(reusable_path);
let result = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&last_used)
.and_then(|file| file.set_modified(SystemTime::now()));
if let Err(err) = result {
tracing::warn!(
path = %last_used.display(),
error = %err,
"failed to touch reusable audit worktree sidecar; staleness signal may not update",
);
}
}
pub(super) fn resolve_cache_max_age(opts: &AuditOptions<'_>) -> Option<Duration> {
if let Ok(raw) = std::env::var(AUDIT_CACHE_MAX_AGE_ENV) {
if let Ok(days) = raw.trim().parse::<u32>() {
return days_to_duration(days);
}
tracing::debug!(
value = %raw,
"FALLOW_AUDIT_CACHE_MAX_AGE_DAYS is not a valid u32; falling back to config/default",
);
}
if let Some(days) = load_audit_config(opts).and_then(|c| c.cache_max_age_days) {
return days_to_duration(days);
}
days_to_duration(DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS)
}
pub(super) fn days_to_duration(days: u32) -> Option<Duration> {
if days == 0 {
return None;
}
Some(Duration::from_secs(u64::from(days) * 86_400))
}
fn load_audit_config(opts: &AuditOptions<'_>) -> Option<AuditConfig> {
if let Some(path) = opts.config_path {
return fallow_config::FallowConfig::load(path)
.ok()
.map(|config| config.audit);
}
fallow_config::FallowConfig::find_and_load(opts.root)
.ok()
.flatten()
.map(|(config, _path)| config.audit)
}
pub(super) fn sweep_old_reusable_caches(repo_root: &Path, max_age: Option<Duration>, quiet: bool) {
let Some(worktrees) = list_audit_worktrees(repo_root) else {
return;
};
let now = SystemTime::now();
let mut removed: u32 = 0;
for path in worktrees {
if !is_reusable_audit_worktree_path(&path) {
continue;
}
if !path.exists() {
let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
continue;
};
if path.exists() {
continue;
}
remove_audit_worktree(repo_root, &path);
let _ = std::fs::remove_file(reusable_worktree_last_used_path(&path));
removed += 1;
continue;
}
let Some(max_age) = max_age else {
continue;
};
let sidecar = reusable_worktree_last_used_path(&path);
let sidecar_mtime = std::fs::metadata(&sidecar)
.ok()
.and_then(|m| m.modified().ok());
let Some(mtime) = sidecar_mtime else {
touch_last_used(&path);
continue;
};
let Ok(age) = now.duration_since(mtime) else {
continue;
};
if age < max_age {
continue;
}
let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
continue;
};
remove_audit_worktree(repo_root, &path);
let dir_removed = match std::fs::remove_dir_all(&path) {
Ok(()) => true,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
Err(err) => {
tracing::warn!(
path = %path.display(),
error = %err,
"failed to remove stale reusable audit worktree directory; entry may leak",
);
false
}
};
let _ = std::fs::remove_file(&sidecar);
if dir_removed {
removed += 1;
}
}
if removed == 0 {
return;
}
let mut command = Command::new("git");
command
.args(["worktree", "prune", "--expire=now"])
.current_dir(repo_root);
clear_ambient_git_env(&mut command);
let _ = command.output();
tracing::info!(
count = removed,
"reclaimed stale audit base-snapshot caches",
);
if !quiet {
let s = plural(removed as usize);
let _ = writeln!(
std::io::stderr(),
"fallow: reclaimed {removed} stale base-snapshot cache{s}",
);
}
}
fn reusable_audit_worktree_path(repo_root: &Path, base_sha: &str) -> PathBuf {
let repo_root = git_toplevel(repo_root).unwrap_or_else(|| repo_root.to_path_buf());
let repo_root = dunce::canonicalize(&repo_root).unwrap_or(repo_root);
let repo_hash = xxh3_64(repo_root.to_string_lossy().as_bytes());
let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
std::env::temp_dir().join(format!(
"fallow-audit-base-cache-{repo_hash:016x}-{sha_prefix}"
))
}
fn reusable_audit_worktree_is_ready(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
return false;
}
git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha)
}
fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
let Some(worktrees) = list_audit_worktrees(repo_root) else {
return false;
};
worktrees.iter().any(|worktree| paths_equal(worktree, path))
}
pub(super) fn paths_equal(left: &Path, right: &Path) -> bool {
if left == right {
return true;
}
match (dunce::canonicalize(left), dunce::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
const MATERIALIZED_CONTEXT_DIRS: &[&str] = &["node_modules", ".nuxt", ".astro"];
pub(super) fn materialize_base_dependency_context(repo_root: &Path, worktree_path: &Path) {
for &name in MATERIALIZED_CONTEXT_DIRS {
let source = repo_root.join(name);
if !source.is_dir() {
continue;
}
let destination = worktree_path.join(name);
if destination.is_dir() {
continue;
}
if let Ok(metadata) = std::fs::symlink_metadata(&destination) {
if !metadata.file_type().is_symlink() {
continue;
}
let _ = std::fs::remove_file(&destination);
}
let _ = symlink_dependency_dir(&source, &destination);
}
}
#[cfg(unix)]
fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(source, destination)
}
#[cfg(windows)]
fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(source, destination)
}
pub(super) fn remove_audit_worktree(repo_root: &Path, path: &Path) {
let mut command = Command::new("git");
command
.args([
"worktree",
"remove",
"--force",
path.to_string_lossy().as_ref(),
])
.current_dir(repo_root);
clear_ambient_git_env(&mut command);
match crate::signal::scoped_child::output(&mut command) {
Ok(output) => {
if !output.status.success() && path.exists() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
path = %path.display(),
stderr = %stderr.trim(),
"git worktree remove failed; the directory remains and may leak",
);
}
}
Err(err) => {
tracing::warn!(
path = %path.display(),
error = %err,
"git worktree remove subprocess failed to spawn",
);
}
}
}
pub(super) fn sweep_orphan_audit_worktrees(repo_root: &Path) {
let Some(worktrees) = list_audit_worktrees(repo_root) else {
return;
};
let mut removed_any = false;
for path in worktrees {
if !is_fallow_audit_worktree_path(&path)
|| is_reusable_audit_worktree_path(&path)
|| audit_worktree_process_is_alive(&path)
{
continue;
}
remove_audit_worktree(repo_root, &path);
let _ = std::fs::remove_dir_all(&path);
removed_any = true;
}
if removed_any {
let mut command = Command::new("git");
command
.args(["worktree", "prune", "--expire=now"])
.current_dir(repo_root);
clear_ambient_git_env(&mut command);
let _ = command.output();
}
}
pub(super) fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
let mut command = Command::new("git");
command
.args(["worktree", "list", "--porcelain"])
.current_dir(repo_root);
clear_ambient_git_env(&mut command);
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
Some(parse_worktree_list(&String::from_utf8_lossy(
&output.stdout,
)))
}
pub(super) fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
output
.lines()
.filter_map(|line| line.strip_prefix("worktree "))
.map(PathBuf::from)
.filter(|path| is_fallow_audit_worktree_path(path))
.collect()
}
pub(super) fn is_fallow_audit_worktree_path(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
}
pub(super) fn is_reusable_audit_worktree_path(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
}
fn path_is_inside_temp_dir(path: &Path) -> bool {
let temp = std::env::temp_dir();
let simple_path = dunce::simplified(path);
let simple_temp = dunce::simplified(&temp);
if simple_path.starts_with(simple_temp) {
return true;
}
let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
return false;
};
let simple_canonical_temp = dunce::simplified(&canonical_temp);
simple_path.starts_with(simple_canonical_temp)
|| std::fs::canonicalize(path).is_ok_and(|canonical_path| {
dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
})
}
fn audit_worktree_process_is_alive(path: &Path) -> bool {
let Some(pid) = path
.file_name()
.and_then(|name| name.to_str())
.and_then(audit_worktree_pid)
else {
return false;
};
process_is_alive(pid)
}
pub(super) fn audit_worktree_pid(name: &str) -> Option<u32> {
name.strip_prefix("fallow-audit-base-")?
.split('-')
.next()?
.parse()
.ok()
}
#[cfg(unix)]
pub(super) fn process_is_alive(pid: u32) -> bool {
Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.is_ok_and(|output| output.status.success())
}
#[cfg(windows)]
pub(super) fn process_is_alive(pid: u32) -> bool {
windows_process::is_alive(pid)
}
#[cfg(not(any(unix, windows)))]
pub(super) fn process_is_alive(_pid: u32) -> bool {
true
}
#[cfg(windows)]
#[allow(
unsafe_code,
reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
)]
mod windows_process {
use windows_sys::Win32::Foundation::{
CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
WAIT_OBJECT_0,
};
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
};
struct ProcessHandle(HANDLE);
impl Drop for ProcessHandle {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0);
}
}
}
pub(super) fn is_alive(pid: u32) -> bool {
let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if raw.is_null() {
let err = unsafe { GetLastError() };
#[expect(
clippy::match_same_arms,
reason = "named arm documents the cross-session case"
)]
return match err {
ERROR_INVALID_PARAMETER => false,
ERROR_ACCESS_DENIED => true,
_ => true,
};
}
let handle = ProcessHandle(raw);
let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
wait_result != WAIT_OBJECT_0
}
}
impl Drop for BaseWorktree {
fn drop(&mut self) {
if self.persistent {
return;
}
remove_audit_worktree(&self.repo_root, &self.path);
let _ = std::fs::remove_dir_all(&self.path);
}
}