use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime};
use fallow_engine::changed_files::clear_ambient_git_env;
pub use fallow_engine::repo_refs::materialize_base_dependency_context;
use rustc_hash::{FxHashMap, FxHashSet};
use xxhash_rust::xxh3::xxh3_64;
use crate::report::plural;
pub struct BaseWorktree {
path: PathBuf,
persistent: bool,
_reusable_lock: Option<ReusableWorktreeLock>,
}
impl BaseWorktree {
pub 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 = non_reusable_worktree_path()?;
let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
repo_root,
guard.path(),
base_ref,
) {
tracing::debug!(
base_ref,
error = %error,
"could not materialize non-reusable audit base worktree",
);
return None;
}
if let Err(error) = unregister_worktree(repo_root, guard.path()) {
tracing::debug!(
path = %guard.path().display(),
error = %error,
"could not deregister non-reusable audit base worktree",
);
return None;
}
guard.defuse();
drop(guard);
let worktree = Self {
path,
persistent: false,
_reusable_lock: None,
};
materialize_base_dependency_context(repo_root, worktree.path());
Some(worktree)
}
pub fn reuse_or_create(repo_root: &Path, base_sha: &str) -> Option<Self> {
let path = reusable_audit_worktree_path(repo_root);
let reusable_lock =
ReusableWorktreeLock::try_acquire(&path, "falling back to non-reusable worktree")?;
if reusable_audit_worktree_is_ready(&path, base_sha)
|| try_migrate_registered_current_cache(repo_root, &path, base_sha)
{
let worktree = Self {
path,
persistent: true,
_reusable_lock: Some(reusable_lock),
};
materialize_base_dependency_context(repo_root, worktree.path());
record_last_used(worktree.path(), repo_root);
return Some(worktree);
}
if let Err(error) = remove_file_if_exists(&reusable_worktree_sha_path(&path)) {
tracing::debug!(
path = %path.display(),
error = %error,
"could not clear reusable audit worktree readiness before rebuild",
);
return None;
}
if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
tracing::debug!(
path = %path.display(),
error = %error,
"could not remove stale reusable audit worktree before rebuild",
);
return None;
}
let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
repo_root,
guard.path(),
base_sha,
) {
tracing::debug!(
base_sha,
error = %error,
"could not materialize reusable audit base worktree",
);
return None;
}
if let Err(error) = unregister_worktree_checked(repo_root, guard.path()) {
tracing::debug!(
path = %guard.path().display(),
error = %error,
"could not deregister reusable audit base worktree",
);
return None;
}
guard.defuse();
drop(guard);
let readiness_published = write_reusable_sha(&path, base_sha).is_ok();
let worktree = Self {
path,
persistent: true,
_reusable_lock: Some(reusable_lock),
};
materialize_base_dependency_context(repo_root, worktree.path());
if readiness_published {
record_last_used(worktree.path(), repo_root);
}
Some(worktree)
}
pub fn path(&self) -> &Path {
&self.path
}
}
fn non_reusable_worktree_path() -> Option<PathBuf> {
static SEQ: AtomicU64 = AtomicU64::new(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.ok()?
.as_nanos();
Some(std::env::temp_dir().join(format!(
"fallow-audit-base-{}-{nanos}-{seq}",
std::process::id()
)))
}
pub struct WorktreeCleanupGuard<'a> {
repo_root: PathBuf,
path: &'a Path,
armed: bool,
}
impl<'a> WorktreeCleanupGuard<'a> {
pub fn new(repo_root: &Path, path: &'a Path) -> Self {
Self {
repo_root: repo_root.to_path_buf(),
path,
armed: true,
}
}
pub fn path(&self) -> &Path {
self.path
}
pub 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 struct ReusableWorktreeLock {
file: std::fs::File,
}
impl ReusableWorktreeLock {
pub fn try_acquire(reusable_path: &Path, context: &'static str) -> Option<Self> {
let lock_path = reusable_worktree_lock_path(reusable_path);
let file = open_or_create_owned_sidecar(&lock_path).ok()?;
match file.try_lock() {
Ok(()) => Some(Self { file }),
Err(std::fs::TryLockError::WouldBlock) => {
tracing::debug!(
path = %lock_path.display(),
context,
"reusable audit worktree lock contended",
);
None
}
Err(std::fs::TryLockError::Error(err)) => {
tracing::debug!(
path = %lock_path.display(),
error = %err,
context,
"could not acquire reusable audit worktree lock",
);
None
}
}
}
}
impl Drop for ReusableWorktreeLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
pub fn reusable_worktree_lock_path(reusable_path: &Path) -> PathBuf {
sidecar_path(reusable_path, REUSABLE_LOCK_SUFFIX)
}
fn sidecar_path(reusable_path: &Path, suffix: &str) -> PathBuf {
let mut name = reusable_path
.file_name()
.map(std::ffi::OsString::from)
.unwrap_or_default();
name.push(suffix);
reusable_path
.parent()
.map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
}
pub fn reusable_worktree_sha_path(reusable_path: &Path) -> PathBuf {
sidecar_path(reusable_path, REUSABLE_SHA_SUFFIX)
}
fn write_reusable_sha(reusable_path: &Path, base_sha: &str) -> std::io::Result<()> {
static SEQ: AtomicU64 = AtomicU64::new(0);
let sha_path = reusable_worktree_sha_path(reusable_path);
let sequence = SEQ.fetch_add(1, Ordering::Relaxed);
let temp_path = sidecar_path(
reusable_path,
&format!(
"{REUSABLE_SHA_SUFFIX}.tmp-{}-{sequence}",
std::process::id()
),
);
let result = (|| {
let mut options = std::fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options.open(&temp_path)?;
file.write_all(format!("{base_sha}\n").as_bytes())?;
file.sync_all()?;
std::fs::rename(&temp_path, &sha_path)
})();
if let Err(err) = &result {
let _ = std::fs::remove_file(&temp_path);
tracing::debug!(
path = %sha_path.display(),
error = %err,
"failed to write reusable audit worktree .sha sidecar; next run will rebuild",
);
}
result
}
const DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS: u32 = 30;
const SECONDS_PER_DAY: u64 = 86_400;
const AUDIT_CACHE_MAX_AGE_ENV: &str = "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS";
const REUSABLE_LAST_USED_SUFFIX: &str = ".last-used";
const REUSABLE_SHA_SUFFIX: &str = ".sha";
const REUSABLE_LOCK_SUFFIX: &str = ".lock";
const UNREGISTERED_GITDIR_STUB: &str = "gitdir: fallow-audit-unregistered\n";
pub fn reusable_worktree_last_used_path(reusable_path: &Path) -> PathBuf {
sidecar_path(reusable_path, REUSABLE_LAST_USED_SUFFIX)
}
pub fn touch_last_used(reusable_path: &Path) {
stamp_last_used(reusable_path, None);
}
pub fn record_last_used(reusable_path: &Path, owner_root: &Path) {
stamp_last_used(reusable_path, Some(owner_root));
}
fn stamp_last_used(reusable_path: &Path, owner_root: Option<&Path>) {
let last_used = reusable_worktree_last_used_path(reusable_path);
let result = open_or_create_owned_sidecar(&last_used).and_then(|mut file| {
if let Some(owner_root) = owner_root {
file.set_len(0)?;
file.write_all(format!("{}\n", owner_root.display()).as_bytes())?;
}
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",
);
}
}
fn read_last_used_owner(reusable_path: &Path) -> Option<PathBuf> {
const MAX_OWNER_SIDECAR_BYTES: u64 = 4096;
let sidecar = reusable_worktree_last_used_path(reusable_path);
let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_OWNER_SIDECAR_BYTES {
return None;
}
let mut contents = String::new();
std::fs::File::open(sidecar)
.ok()?
.take(MAX_OWNER_SIDECAR_BYTES)
.read_to_string(&mut contents)
.ok()?;
let owner = contents.trim();
if owner.is_empty() {
return None;
}
Some(PathBuf::from(owner))
}
fn open_or_create_owned_sidecar(path: &Path) -> std::io::Result<std::fs::File> {
match std::fs::symlink_metadata(path) {
Ok(metadata) if sidecar_metadata_is_trusted(&metadata) => {
std::fs::OpenOptions::new().write(true).open(path)
}
Ok(_) => Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"refusing to open an untrusted audit cache sidecar",
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let mut options = std::fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
options.open(path)
}
Err(error) => Err(error),
}
}
#[cfg(unix)]
fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt as _;
metadata_is_regular_file(metadata) && metadata.uid() == rustix::process::geteuid().as_raw()
}
#[cfg(not(unix))]
fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
metadata_is_regular_file(metadata)
}
#[expect(
clippy::filetype_is_file,
reason = "security-sensitive sidecars and gitfiles must be regular files, not arbitrary non-directories"
)]
fn metadata_is_regular_file(metadata: &std::fs::Metadata) -> bool {
metadata.file_type().is_file()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheMaxAgeSource {
Flag,
Env,
Config,
Default,
}
impl CacheMaxAgeSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Flag => "flag",
Self::Env => "env",
Self::Config => "config",
Self::Default => "default",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ResolvedCacheMaxAge {
pub max_age: Option<Duration>,
pub days: u32,
pub source: CacheMaxAgeSource,
}
pub fn resolve_cache_max_age_with_options(
root: &Path,
config_path: Option<&PathBuf>,
allow_remote_extends: bool,
) -> Option<Duration> {
resolve_cache_max_age_with_source(root, config_path, allow_remote_extends, None).max_age
}
pub fn resolve_cache_max_age_with_source(
root: &Path,
config_path: Option<&PathBuf>,
allow_remote_extends: bool,
flag_days: Option<u32>,
) -> ResolvedCacheMaxAge {
if let Some(days) = flag_days {
return ResolvedCacheMaxAge {
max_age: days_to_duration(days),
days,
source: CacheMaxAgeSource::Flag,
};
}
if let Ok(raw) = std::env::var(AUDIT_CACHE_MAX_AGE_ENV) {
if let Ok(days) = raw.trim().parse::<u32>() {
return ResolvedCacheMaxAge {
max_age: days_to_duration(days),
days,
source: CacheMaxAgeSource::Env,
};
}
tracing::warn!(
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(root, config_path, allow_remote_extends)
.and_then(|c| c.cache_max_age_days)
{
return ResolvedCacheMaxAge {
max_age: days_to_duration(days),
days,
source: CacheMaxAgeSource::Config,
};
}
ResolvedCacheMaxAge {
max_age: days_to_duration(DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS),
days: DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS,
source: CacheMaxAgeSource::Default,
}
}
pub fn days_to_duration(days: u32) -> Option<Duration> {
if days == 0 {
return None;
}
Some(Duration::from_secs(u64::from(days) * SECONDS_PER_DAY))
}
fn load_audit_config(
root: &Path,
config_path: Option<&PathBuf>,
allow_remote_extends: bool,
) -> Option<fallow_config::AuditConfig> {
let options = fallow_config::ConfigLoadOptions {
allow_remote_extends,
};
if let Some(path) = config_path {
return fallow_config::FallowConfig::load_with_options(path, options)
.ok()
.map(|config| config.audit);
}
fallow_config::FallowConfig::find_and_load_with_options(root, options)
.ok()
.flatten()
.map(|(config, _path)| config.audit)
}
pub fn sweep_old_reusable_caches(repo_root: &Path, max_age: Option<Duration>, quiet: bool) {
sweep_old_reusable_caches_in(repo_root, max_age, quiet, &std::env::temp_dir());
}
pub fn sweep_old_reusable_caches_in(
repo_root: &Path,
max_age: Option<Duration>,
quiet: bool,
scan_root: &Path,
) {
let report = sweep_reusable_caches_with_report(
repo_root,
max_age,
scan_root,
SweepMode::Apply,
SweepSizes::Skip,
);
log_sweep_entries(&report, SweepMode::Apply, max_age);
let removed = report.removed;
if removed == 0 {
return;
}
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}",
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SweepMode {
Apply,
DryRun,
}
impl SweepMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Apply => "apply",
Self::DryRun => "dry-run",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SweepSizes {
Skip,
Measure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SweepPass {
Owned,
Legacy,
Foreign,
}
impl SweepPass {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Owned => "owned",
Self::Legacy => "legacy",
Self::Foreign => "foreign",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SweepDecision {
Removed,
Kept,
Skipped,
Failed,
}
impl SweepDecision {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Removed => "removed",
Self::Kept => "kept",
Self::Skipped => "skipped",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SweepDisposition {
ReclaimedOrphan,
ReclaimedAged,
ReclaimedOwnerMissing,
ReclaimedLegacyRegistered,
KeptLegacyDeregistered,
KeptFresh,
KeptOwnerLive,
KeptOwnerUnverifiable(std::io::ErrorKind),
KeptAgeGcDisabled,
KeptGraceSeeded,
KeptLockOnly,
KeptRecreated,
KeptNotOwned,
SkippedLocked,
RemoveFailed,
}
impl SweepDisposition {
#[must_use]
pub const fn decision(self) -> SweepDecision {
match self {
Self::ReclaimedOrphan
| Self::ReclaimedAged
| Self::ReclaimedOwnerMissing
| Self::ReclaimedLegacyRegistered => SweepDecision::Removed,
Self::KeptLegacyDeregistered
| Self::KeptFresh
| Self::KeptOwnerLive
| Self::KeptOwnerUnverifiable(_)
| Self::KeptAgeGcDisabled
| Self::KeptGraceSeeded
| Self::KeptLockOnly
| Self::KeptRecreated
| Self::KeptNotOwned => SweepDecision::Kept,
Self::SkippedLocked => SweepDecision::Skipped,
Self::RemoveFailed => SweepDecision::Failed,
}
}
#[must_use]
pub const fn reason(self) -> &'static str {
match self {
Self::ReclaimedOrphan => "orphaned-sidecars",
Self::ReclaimedAged => "aged-out",
Self::ReclaimedOwnerMissing => "owner-missing",
Self::ReclaimedLegacyRegistered => "legacy-registered",
Self::KeptLegacyDeregistered => "legacy-deregistered",
Self::KeptFresh => "fresh",
Self::KeptOwnerLive => "owner-live",
Self::KeptOwnerUnverifiable(_) => "owner-unverifiable",
Self::KeptAgeGcDisabled => "age-gc-disabled",
Self::KeptGraceSeeded => "grace-seeded",
Self::KeptLockOnly => "lock-only",
Self::KeptRecreated => "recreated",
Self::KeptNotOwned => "not-owned",
Self::SkippedLocked => "lock-contention",
Self::RemoveFailed => "remove-failed",
}
}
const fn counts_toward_summary(self) -> bool {
matches!(
self,
Self::ReclaimedOrphan | Self::ReclaimedAged | Self::ReclaimedOwnerMissing
)
}
}
#[derive(Debug, Clone)]
pub struct SweepEntry {
pub path: PathBuf,
pub pass: SweepPass,
pub disposition: SweepDisposition,
pub age_days: Option<u64>,
pub owner_root: Option<PathBuf>,
pub size_bytes: Option<u64>,
}
#[derive(Debug, Default)]
pub struct SweepReport {
pub entries: Vec<SweepEntry>,
pub removed: u32,
}
impl SweepReport {
fn push(&mut self, entry: SweepEntry) {
if entry.disposition.counts_toward_summary() {
self.removed += 1;
}
self.entries.push(entry);
}
}
const GC_LOCK_CONTEXT: &str = "gc sweep skips the entry";
pub fn log_sweep_entries(report: &SweepReport, mode: SweepMode, max_age: Option<Duration>) {
let threshold_days = max_age.map(|age| age.as_secs() / SECONDS_PER_DAY);
for entry in &report.entries {
let owner_probe_error = match entry.disposition {
SweepDisposition::KeptOwnerUnverifiable(kind) => Some(kind),
_ => None,
};
tracing::debug!(
path = %entry.path.display(),
pass = entry.pass.as_str(),
mode = mode.as_str(),
decision = entry.disposition.decision().as_str(),
reason = entry.disposition.reason(),
age_days = entry.age_days,
threshold_days,
owner_root = entry
.owner_root
.as_ref()
.map(|owner| tracing::field::display(owner.display())),
owner_probe_error = owner_probe_error.map(tracing::field::debug),
"audit cache sweep considered entry",
);
}
}
pub fn sweep_reusable_caches_with_report(
repo_root: &Path,
max_age: Option<Duration>,
scan_root: &Path,
mode: SweepMode,
sizes: SweepSizes,
) -> SweepReport {
let now = SystemTime::now();
let mut report = SweepReport::default();
legacy_registered_pass(repo_root, mode, sizes, now, &mut report);
let owned_path = reusable_audit_worktree_path(repo_root);
let mut paths = vec![owned_path.clone()];
paths.extend(scan_legacy_reusable_cache_paths(repo_root, scan_root));
paths.sort();
paths.dedup();
let scoped: FxHashSet<&PathBuf> = paths.iter().collect();
let foreign: Vec<PathBuf> = scan_all_reusable_cache_paths(scan_root)
.into_iter()
.filter(|path| !scoped.contains(path))
.collect();
drop(scoped);
let mut size_map = measure_entry_sizes(sizes, paths.iter().chain(foreign.iter()));
for path in &paths {
if !cache_entry_has_presence(path) {
continue;
}
let pass = if *path == owned_path {
SweepPass::Owned
} else {
SweepPass::Legacy
};
let (age_days, owner_root) = entry_probe_metadata(path, now);
let disposition = match mode {
SweepMode::Apply => reclaim_reusable_cache_entry(repo_root, path, max_age, now),
SweepMode::DryRun => classify_entry_dry_run(path, max_age, now, OwnerGate::Off),
};
report.push(SweepEntry {
path: path.clone(),
pass,
disposition,
age_days,
owner_root,
size_bytes: size_map.remove(path).flatten(),
});
}
for path in &foreign {
let (age_days, owner_root) = entry_probe_metadata(path, now);
let disposition = match mode {
SweepMode::Apply => reclaim_foreign_cache_entry(repo_root, path, max_age, now),
SweepMode::DryRun => classify_entry_dry_run(path, max_age, now, OwnerGate::On),
};
report.push(SweepEntry {
path: path.clone(),
pass: SweepPass::Foreign,
disposition,
age_days,
owner_root,
size_bytes: size_map.remove(path).flatten(),
});
}
report
}
fn entry_probe_metadata(path: &Path, now: SystemTime) -> (Option<u64>, Option<PathBuf>) {
let age_days = last_used_mtime(path)
.and_then(|mtime| now.duration_since(mtime).ok())
.map(|age| age.as_secs() / SECONDS_PER_DAY);
(age_days, read_last_used_owner(path))
}
fn legacy_registered_pass(
repo_root: &Path,
mode: SweepMode,
sizes: SweepSizes,
now: SystemTime,
report: &mut SweepReport,
) {
let Some(worktrees) = list_audit_worktrees(repo_root) else {
return;
};
let candidates: Vec<PathBuf> = worktrees
.into_iter()
.filter(|path| is_reusable_audit_worktree_path(path))
.collect();
let mut size_map = measure_entry_sizes(sizes, candidates.iter());
let owned_path = reusable_audit_worktree_path(repo_root);
let mut deregistered = false;
for path in candidates {
let pass = if paths_equal(&path, &owned_path) {
SweepPass::Owned
} else {
SweepPass::Legacy
};
let (age_days, owner_root) = entry_probe_metadata(&path, now);
let size_bytes = size_map.remove(&path).flatten();
let is_current_path = pass == SweepPass::Owned;
let disposition = match mode {
SweepMode::DryRun => {
if !audit_worktree_is_registered(repo_root, &path) {
continue;
}
if is_current_path {
SweepDisposition::KeptLegacyDeregistered
} else {
SweepDisposition::ReclaimedLegacyRegistered
}
}
SweepMode::Apply => {
let Some(_lock) = ReusableWorktreeLock::try_acquire(
&path,
"legacy deregistration skips the entry",
) else {
report.push(SweepEntry {
path,
pass,
disposition: SweepDisposition::SkippedLocked,
age_days,
owner_root,
size_bytes,
});
continue;
};
if !audit_worktree_is_registered(repo_root, &path) {
continue;
}
let head = is_current_path
.then(|| legacy_reusable_sha(&path))
.flatten();
if unregister_worktree_checked(repo_root, &path).is_err() {
report.push(SweepEntry {
path,
pass,
disposition: SweepDisposition::RemoveFailed,
age_days,
owner_root,
size_bytes,
});
continue;
}
let disposition = if is_current_path {
if let Some(head) = head {
let _ = write_reusable_sha(&path, &head);
}
SweepDisposition::KeptLegacyDeregistered
} else if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
tracing::warn!(
path = %path.display(),
error = %error,
"failed to remove released SHA-keyed audit cache",
);
SweepDisposition::RemoveFailed
} else {
SweepDisposition::ReclaimedLegacyRegistered
};
deregistered = true;
disposition
}
};
report.push(SweepEntry {
path,
pass,
disposition,
age_days,
owner_root,
size_bytes,
});
}
if deregistered {
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();
}
}
fn legacy_reusable_sha(path: &Path) -> Option<String> {
if reusable_worktree_sha_path(path).exists()
|| !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
{
return None;
}
git_rev_parse(path, "HEAD")
}
fn scan_legacy_reusable_cache_paths(repo_root: &Path, scan_root: &Path) -> Vec<PathBuf> {
let Some(prefix) = legacy_reusable_cache_repo_prefix(repo_root) else {
return Vec::new();
};
scan_cache_paths_with_hex_suffix(&prefix, scan_root)
}
fn scan_root_owned_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
let Some(prefix) = root_owned_cache_repo_prefix(repo_root) else {
return Vec::new();
};
scan_cache_paths_with_hex_suffix(&prefix, &std::env::temp_dir())
}
fn scan_all_reusable_cache_paths(scan_root: &Path) -> Vec<PathBuf> {
const GLOBAL_CACHE_PREFIX: &str = "fallow-audit-base-cache-";
let Ok(entries) = std::fs::read_dir(scan_root) else {
return Vec::new();
};
let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
let mut paths = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let cache_name = strip_cache_sidecar_suffix(name);
let Some(hash_suffix) = cache_name.strip_prefix(GLOBAL_CACHE_PREFIX) else {
continue;
};
if !cache_hash_suffix_is_valid(hash_suffix) {
continue;
}
let path = scan_root.join(cache_name);
if seen.insert(path.clone()) {
paths.push(path);
}
}
paths
}
fn cache_hash_suffix_is_valid(suffix: &str) -> bool {
fn is_hex16(part: &str) -> bool {
part.len() == 16 && part.bytes().all(|byte| byte.is_ascii_hexdigit())
}
if let Some((repo, root)) = suffix.split_once("-root-") {
return is_hex16(repo) && is_hex16(root);
}
suffix
.split_once('-')
.is_some_and(|(repo, sha)| is_hex16(repo) && is_hex16(sha))
}
fn scan_cache_paths_with_hex_suffix(prefix: &str, scan_root: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(scan_root) else {
return Vec::new();
};
let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
let mut paths = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let cache_name = strip_cache_sidecar_suffix(name);
let Some(hash_suffix) = cache_name.strip_prefix(prefix) else {
continue;
};
if hash_suffix.len() != 16 || !hash_suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
continue;
}
let path = scan_root.join(cache_name);
if seen.insert(path.clone()) {
paths.push(path);
}
}
paths
}
fn strip_cache_sidecar_suffix(name: &str) -> &str {
for suffix in [
REUSABLE_LAST_USED_SUFFIX,
REUSABLE_SHA_SUFFIX,
REUSABLE_LOCK_SUFFIX,
] {
if let Some(stripped) = name.strip_suffix(suffix) {
return stripped;
}
}
name
}
fn reclaim_reusable_cache_entry(
repo_root: &Path,
path: &Path,
max_age: Option<Duration>,
now: SystemTime,
) -> SweepDisposition {
if !path.exists() {
return reclaim_orphan_cache_entry(repo_root, path);
}
let Some(max_age) = max_age else {
return SweepDisposition::KeptAgeGcDisabled;
};
reclaim_aged_cache_entry(repo_root, path, max_age, now)
}
pub fn reclaim_orphan_cache_entry(repo_root: &Path, path: &Path) -> SweepDisposition {
let Some(_lock) = ReusableWorktreeLock::try_acquire(path, GC_LOCK_CONTEXT) else {
return SweepDisposition::SkippedLocked;
};
if path.exists() {
return SweepDisposition::KeptRecreated;
}
match remove_cache_entry_for_sweep(repo_root, path) {
Ok(CacheRemovalOutcome::Removed) => SweepDisposition::ReclaimedOrphan,
Ok(CacheRemovalOutcome::NothingLeft) => SweepDisposition::KeptLockOnly,
Ok(CacheRemovalOutcome::NotOwned) => SweepDisposition::KeptNotOwned,
Err(_) => SweepDisposition::RemoveFailed,
}
}
fn reclaim_aged_cache_entry(
repo_root: &Path,
path: &Path,
max_age: Duration,
now: SystemTime,
) -> SweepDisposition {
let Some(mtime) = last_used_mtime(path) else {
record_last_used(path, repo_root);
return SweepDisposition::KeptGraceSeeded;
};
remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
}
fn reclaim_foreign_cache_entry(
repo_root: &Path,
path: &Path,
max_age: Option<Duration>,
now: SystemTime,
) -> SweepDisposition {
if !path.exists() {
return reclaim_orphan_cache_entry(repo_root, path);
}
let mut owner_missing = false;
if let Some(owner) = read_last_used_owner(path) {
match probe_owner_liveness(&owner) {
OwnerLiveness::Live => return SweepDisposition::KeptOwnerLive,
OwnerLiveness::Unverifiable(kind) => {
return SweepDisposition::KeptOwnerUnverifiable(kind);
}
OwnerLiveness::Dead => owner_missing = true,
}
}
let Some(max_age) = max_age else {
if owner_missing {
return remove_cache_entry_under_lock(
repo_root,
path,
SweepDisposition::ReclaimedOwnerMissing,
);
}
return SweepDisposition::KeptAgeGcDisabled;
};
let Some(mtime) = last_used_mtime(path) else {
touch_last_used(path);
return SweepDisposition::KeptGraceSeeded;
};
remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
}
enum OwnerLiveness {
Live,
Dead,
Unverifiable(std::io::ErrorKind),
}
fn probe_owner_liveness(owner: &Path) -> OwnerLiveness {
match std::fs::metadata(owner) {
Ok(_) => OwnerLiveness::Live,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => OwnerLiveness::Dead,
Err(error) => OwnerLiveness::Unverifiable(error.kind()),
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum OwnerGate {
On,
Off,
}
fn classify_entry_dry_run(
path: &Path,
max_age: Option<Duration>,
now: SystemTime,
owner_gate: OwnerGate,
) -> SweepDisposition {
if !path.exists() {
if !reusable_cache_entry_exists(path) {
return SweepDisposition::KeptLockOnly;
}
return classify_would_remove(path, SweepDisposition::ReclaimedOrphan);
}
let mut owner_missing = false;
if owner_gate == OwnerGate::On
&& let Some(owner) = read_last_used_owner(path)
{
match probe_owner_liveness(&owner) {
OwnerLiveness::Live => return SweepDisposition::KeptOwnerLive,
OwnerLiveness::Unverifiable(kind) => {
return SweepDisposition::KeptOwnerUnverifiable(kind);
}
OwnerLiveness::Dead => owner_missing = true,
}
}
let Some(max_age) = max_age else {
if owner_missing {
return classify_would_remove(path, SweepDisposition::ReclaimedOwnerMissing);
}
return SweepDisposition::KeptAgeGcDisabled;
};
let Some(mtime) = last_used_mtime(path) else {
return SweepDisposition::KeptGraceSeeded;
};
let Ok(age) = now.duration_since(mtime) else {
return SweepDisposition::KeptFresh;
};
if age < max_age {
return SweepDisposition::KeptFresh;
}
classify_would_remove(path, SweepDisposition::ReclaimedAged)
}
fn classify_would_remove(path: &Path, removed: SweepDisposition) -> SweepDisposition {
match cache_entry_ownership(path) {
Ok(CacheEntryOwnership::Owned) => removed,
Ok(CacheEntryOwnership::Unowned(_)) => SweepDisposition::KeptNotOwned,
Err(_) => SweepDisposition::RemoveFailed,
}
}
fn last_used_mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(reusable_worktree_last_used_path(path))
.ok()
.and_then(|metadata| metadata.modified().ok())
}
fn remove_entry_past_max_age(
repo_root: &Path,
path: &Path,
max_age: Duration,
now: SystemTime,
mtime: SystemTime,
) -> SweepDisposition {
let Ok(age) = now.duration_since(mtime) else {
return SweepDisposition::KeptFresh;
};
if age < max_age {
return SweepDisposition::KeptFresh;
}
remove_cache_entry_under_lock(repo_root, path, SweepDisposition::ReclaimedAged)
}
fn remove_cache_entry_under_lock(
repo_root: &Path,
path: &Path,
removed: SweepDisposition,
) -> SweepDisposition {
let Some(_lock) = ReusableWorktreeLock::try_acquire(path, GC_LOCK_CONTEXT) else {
return SweepDisposition::SkippedLocked;
};
match remove_cache_entry_for_sweep(repo_root, path) {
Ok(CacheRemovalOutcome::Removed) => removed,
Ok(CacheRemovalOutcome::NothingLeft) => SweepDisposition::KeptLockOnly,
Ok(CacheRemovalOutcome::NotOwned) => SweepDisposition::KeptNotOwned,
Err(err) => {
tracing::warn!(
path = %path.display(),
error = %err,
"failed to remove stale reusable audit worktree entry; entry may leak",
);
SweepDisposition::RemoveFailed
}
}
}
enum CacheRemovalOutcome {
Removed,
NothingLeft,
NotOwned,
}
fn remove_cache_entry_for_sweep(
repo_root: &Path,
path: &Path,
) -> std::io::Result<CacheRemovalOutcome> {
if matches!(
cache_entry_ownership(path)?,
CacheEntryOwnership::Unowned(_)
) {
return Ok(CacheRemovalOutcome::NotOwned);
}
remove_reusable_cache_entry_locked(repo_root, path).map(|removed| {
if removed {
CacheRemovalOutcome::Removed
} else {
CacheRemovalOutcome::NothingLeft
}
})
}
fn directory_size_bytes(root: &Path) -> Option<u64> {
let root_entries = std::fs::read_dir(root).ok()?;
let mut total: u64 = 0;
let mut stack = vec![root_entries];
while let Some(entries) = stack.pop() {
for entry in entries.flatten() {
let Ok(metadata) = entry.metadata() else {
continue;
};
if metadata.is_dir() {
if let Ok(child) = std::fs::read_dir(entry.path()) {
stack.push(child);
}
} else if metadata_is_regular_file(&metadata) {
total = total.saturating_add(metadata.len());
}
}
}
Some(total)
}
fn measure_entry_sizes<'a>(
sizes: SweepSizes,
paths: impl Iterator<Item = &'a PathBuf>,
) -> FxHashMap<PathBuf, Option<u64>> {
use rayon::iter::{IntoParallelRefIterator as _, ParallelIterator as _};
if sizes == SweepSizes::Skip {
return FxHashMap::default();
}
let paths: Vec<&PathBuf> = paths.collect();
paths
.par_iter()
.map(|path| ((*path).clone(), directory_size_bytes(path)))
.collect()
}
pub fn canonical_root_hash(root: &Path) -> u64 {
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
xxh3_64(&path_identity_bytes(&canonical_root))
}
#[cfg(unix)]
fn path_identity_bytes(path: &Path) -> Vec<u8> {
use std::os::unix::ffi::OsStrExt as _;
path.as_os_str().as_bytes().to_vec()
}
#[cfg(windows)]
fn path_identity_bytes(path: &Path) -> Vec<u8> {
use std::os::windows::ffi::OsStrExt as _;
path.as_os_str()
.encode_wide()
.flat_map(u16::to_le_bytes)
.collect()
}
#[cfg(not(any(unix, windows)))]
fn path_identity_bytes(path: &Path) -> Vec<u8> {
path.to_string_lossy().as_bytes().to_vec()
}
pub fn reusable_audit_worktree_path(requested_root: &Path) -> PathBuf {
let root_hash = canonical_root_hash(requested_root);
let repo_hash = git_toplevel(requested_root)
.as_deref()
.map_or(root_hash, canonical_root_hash);
std::env::temp_dir().join(format!(
"fallow-audit-base-cache-{repo_hash:016x}-root-{root_hash:016x}"
))
}
fn root_owned_cache_repo_prefix(requested_root: &Path) -> Option<String> {
let git_root = git_toplevel(requested_root)?;
let repo_hash = canonical_root_hash(&git_root);
Some(format!("fallow-audit-base-cache-{repo_hash:016x}-root-"))
}
fn legacy_reusable_cache_repo_prefix(requested_root: &Path) -> Option<String> {
let git_root = git_toplevel(requested_root)?;
let repo_hash = canonical_root_hash(&git_root);
Some(format!("fallow-audit-base-cache-{repo_hash:016x}-"))
}
#[cfg(test)]
pub fn legacy_reusable_audit_worktree_path(
requested_root: &Path,
base_sha: &str,
) -> Option<PathBuf> {
let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
Some(std::env::temp_dir().join(format!(
"{}{sha_prefix}",
legacy_reusable_cache_repo_prefix(requested_root)?
)))
}
fn reusable_audit_worktree_is_ready(path: &Path, base_sha: &str) -> bool {
if !reusable_cache_directory_is_trusted(path) {
return false;
}
let recorded = read_reusable_sha(path);
if recorded.as_deref() != Some(base_sha) {
return false;
}
repair_unregistered_git_stub(path)
}
fn read_reusable_sha(path: &Path) -> Option<String> {
const MAX_SHA_SIDECAR_BYTES: u64 = 129;
let sidecar = reusable_worktree_sha_path(path);
let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_SHA_SIDECAR_BYTES {
return None;
}
let mut contents = String::new();
std::fs::File::open(sidecar)
.ok()?
.take(MAX_SHA_SIDECAR_BYTES)
.read_to_string(&mut contents)
.ok()?;
Some(contents.trim().to_owned())
}
#[cfg(unix)]
fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return false;
};
metadata.file_type().is_dir()
&& metadata.uid() == rustix::process::geteuid().as_raw()
&& metadata.permissions().mode().trailing_zeros() >= 6
}
#[cfg(not(unix))]
fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
}
fn try_migrate_registered_current_cache(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
return false;
}
let head_matches = git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha);
if !head_matches || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
{
return false;
}
if unregister_worktree_checked(repo_root, path).is_err() {
return false;
}
write_reusable_sha(path, base_sha).is_ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuditCacheRemovalReport {
pub found: usize,
pub removed: usize,
pub skipped: usize,
pub dry_run: bool,
}
pub fn remove_reusable_audit_caches(
requested_root: &Path,
dry_run: bool,
) -> std::io::Result<AuditCacheRemovalReport> {
let mut paths = vec![reusable_audit_worktree_path(requested_root)];
paths.extend(scan_legacy_reusable_cache_paths(
requested_root,
&std::env::temp_dir(),
));
if git_toplevel(requested_root).is_some_and(|root| paths_equal(&root, requested_root)) {
paths.extend(scan_root_owned_cache_paths(requested_root));
}
paths.sort();
paths.dedup();
let mut report = AuditCacheRemovalReport {
found: 0,
removed: 0,
skipped: 0,
dry_run,
};
for path in paths {
if !reusable_cache_entry_exists(&path) {
continue;
}
report.found += 1;
if dry_run {
continue;
}
let Some(_lock) =
ReusableWorktreeLock::try_acquire(&path, "cache removal reports the entry as skipped")
else {
report.skipped += 1;
continue;
};
if remove_reusable_cache_entry_locked(requested_root, &path)? {
report.removed += 1;
}
}
Ok(report)
}
fn reusable_cache_entry_exists(path: &Path) -> bool {
path_entry_exists(path)
|| path_entry_exists(&reusable_worktree_sha_path(path))
|| path_entry_exists(&reusable_worktree_last_used_path(path))
}
fn cache_entry_has_presence(path: &Path) -> bool {
reusable_cache_entry_exists(path) || path_entry_exists(&reusable_worktree_lock_path(path))
}
fn path_entry_exists(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok()
}
fn remove_reusable_cache_entry_locked(repo_root: &Path, path: &Path) -> std::io::Result<bool> {
let existed = reusable_cache_entry_exists(path);
ensure_cache_entry_is_owned(path)?;
if trusted_worktree_admin_dir(repo_root, path).is_some() {
unregister_worktree_checked(repo_root, path)?;
}
remove_dir_if_exists(path)?;
remove_file_if_exists(&reusable_worktree_sha_path(path))?;
remove_file_if_exists(&reusable_worktree_last_used_path(path))?;
Ok(existed)
}
enum CacheEntryOwnership {
Owned,
#[cfg_attr(
not(unix),
expect(dead_code, reason = "only the Unix uid probe constructs this variant")
)]
Unowned(PathBuf),
}
#[cfg(unix)]
fn cache_entry_ownership(path: &Path) -> std::io::Result<CacheEntryOwnership> {
use std::os::unix::fs::MetadataExt as _;
let effective_uid = rustix::process::geteuid().as_raw();
for entry in [
path.to_path_buf(),
reusable_worktree_sha_path(path),
reusable_worktree_last_used_path(path),
] {
let metadata = match std::fs::symlink_metadata(&entry) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error),
};
if metadata.uid() != effective_uid {
return Ok(CacheEntryOwnership::Unowned(entry));
}
}
Ok(CacheEntryOwnership::Owned)
}
#[cfg(not(unix))]
#[expect(
clippy::unnecessary_wraps,
reason = "shared cross-platform signature; the Unix ownership check is fallible, non-Unix has no POSIX owner to verify"
)]
fn cache_entry_ownership(_path: &Path) -> std::io::Result<CacheEntryOwnership> {
Ok(CacheEntryOwnership::Owned)
}
fn ensure_cache_entry_is_owned(path: &Path) -> std::io::Result<()> {
match cache_entry_ownership(path)? {
CacheEntryOwnership::Owned => Ok(()),
CacheEntryOwnership::Unowned(entry) => Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"refusing to remove unowned audit cache entry `{}`",
entry.display()
),
)),
}
}
fn remove_dir_if_exists(path: &Path) -> std::io::Result<()> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
pub fn unregister_worktree(repo_root: &Path, path: &Path) -> std::io::Result<()> {
unregister_worktree_checked(repo_root, path)
}
fn unregister_worktree_checked(repo_root: &Path, path: &Path) -> std::io::Result<()> {
if !path.exists() {
return Ok(());
}
let gitfile = path.join(".git");
let metadata = std::fs::symlink_metadata(&gitfile)?;
if !metadata_is_regular_file(&metadata) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"refusing to deregister through a non-file audit worktree .git entry",
));
}
let contents = std::fs::read_to_string(&gitfile)?;
if contents == UNREGISTERED_GITDIR_STUB {
return Ok(());
}
let Some(admin_dir) = trusted_worktree_admin_dir(repo_root, path) else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"refusing to deregister an unverified audit worktree admin entry",
));
};
remove_dir_if_exists(&admin_dir)?;
write_git_stub_safely(&gitfile)
}
fn repair_unregistered_git_stub(path: &Path) -> bool {
let gitfile = path.join(".git");
let Ok(metadata) = std::fs::symlink_metadata(&gitfile) else {
return write_git_stub_safely(&gitfile).is_ok();
};
if !metadata_is_regular_file(&metadata) {
return false;
}
std::fs::read_to_string(&gitfile).is_ok_and(|contents| contents == UNREGISTERED_GITDIR_STUB)
}
fn write_git_stub_safely(gitfile: &Path) -> std::io::Result<()> {
let mut options = std::fs::OpenOptions::new();
options.write(true).truncate(true);
match std::fs::symlink_metadata(gitfile) {
Ok(metadata) if metadata_is_regular_file(&metadata) => {}
Ok(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"refusing to replace non-file audit worktree .git entry",
));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
options.create_new(true);
}
Err(error) => return Err(error),
}
let mut file = options.open(gitfile)?;
file.write_all(UNREGISTERED_GITDIR_STUB.as_bytes())?;
file.sync_all()
}
fn trusted_worktree_admin_dir(repo_root: &Path, path: &Path) -> Option<PathBuf> {
let gitfile = path.join(".git");
let metadata = std::fs::symlink_metadata(&gitfile).ok()?;
if !metadata_is_regular_file(&metadata) {
return None;
}
let contents = std::fs::read_to_string(&gitfile).ok()?;
let admin_dir = parse_worktree_gitdir(&contents)?;
if !is_fallow_admin_dir(&admin_dir) {
return None;
}
let common_dir = fallow_engine::changed_files::resolve_git_common_dir(repo_root).ok()?;
let worktrees_dir = dunce::canonicalize(common_dir.join("worktrees")).ok()?;
let admin_parent = dunce::canonicalize(admin_dir.parent()?).ok()?;
if admin_parent != worktrees_dir {
return None;
}
let backlink = std::fs::read_to_string(admin_dir.join("gitdir")).ok()?;
let expected_gitfile = dunce::canonicalize(&gitfile).ok()?;
let actual_gitfile = dunce::canonicalize(Path::new(backlink.trim())).ok()?;
(actual_gitfile == expected_gitfile).then_some(admin_dir)
}
fn parse_worktree_gitdir(contents: &str) -> Option<PathBuf> {
contents
.lines()
.find_map(|line| line.trim().strip_prefix("gitdir:"))
.map(|rest| PathBuf::from(rest.trim()))
}
fn is_fallow_admin_dir(admin_dir: &Path) -> bool {
admin_dir
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("fallow-audit-base-"))
}
pub fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
let mut command = Command::new("git");
command.args(["rev-parse", rev]).current_dir(root);
clear_ambient_git_env(&mut command);
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn git_toplevel(root: &Path) -> Option<PathBuf> {
let mut command = Command::new("git");
command
.args(["rev-parse", "--show-toplevel"])
.current_dir(root);
clear_ambient_git_env(&mut command);
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
Some(dunce::canonicalize(&path).unwrap_or(path))
}
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 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,
}
}
pub 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 fn sweep_orphan_audit_worktrees(repo_root: &Path) {
sweep_orphan_audit_worktrees_in(repo_root, &std::env::temp_dir());
}
pub fn sweep_orphan_audit_worktrees_in(repo_root: &Path, temp_root: &Path) {
if deregister_legacy_orphan_worktrees(repo_root) {
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();
}
for path in scan_non_reusable_orphan_paths(temp_root) {
let _ = std::fs::remove_dir_all(&path);
}
}
fn deregister_legacy_orphan_worktrees(repo_root: &Path) -> bool {
let Some(worktrees) = list_audit_worktrees(repo_root) else {
return false;
};
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;
}
removed_any
}
fn scan_non_reusable_orphan_paths(temp: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(temp) else {
return Vec::new();
};
let mut paths = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let Some(pid) = audit_worktree_pid(name) else {
continue;
};
if process_is_alive(pid) || !entry.path().is_dir() {
continue;
}
paths.push(temp.join(name));
}
paths
}
pub 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 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 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 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 fn audit_worktree_pid(name: &str) -> Option<u32> {
name.strip_prefix("fallow-audit-base-")?
.split('-')
.next()?
.parse()
.ok()
}
#[cfg(unix)]
pub 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 fn process_is_alive(pid: u32) -> bool {
windows_process::is_alive(pid)
}
#[cfg(not(any(unix, windows)))]
pub 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 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;
}
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_reusable_worktree_paths_are_unique_under_concurrency() {
const N: usize = 64;
let barrier = std::sync::Barrier::new(N);
let paths = std::sync::Mutex::new(Vec::with_capacity(N));
std::thread::scope(|s| {
for _ in 0..N {
let barrier = &barrier;
let paths = &paths;
s.spawn(move || {
barrier.wait();
let path = non_reusable_worktree_path().expect("path should build");
paths.lock().unwrap().push(path);
});
}
});
let mut paths = paths.into_inner().unwrap();
assert_eq!(paths.len(), N);
paths.sort();
paths.dedup();
assert_eq!(paths.len(), N, "non-reusable worktree paths collided");
}
#[test]
fn non_reusable_worktree_path_pid_is_parseable() {
let path = non_reusable_worktree_path().expect("path should build");
let name = path.file_name().unwrap().to_str().unwrap();
assert!(is_fallow_audit_worktree_path(&path));
assert!(!is_reusable_audit_worktree_path(&path));
assert_eq!(audit_worktree_pid(name), Some(std::process::id()));
}
#[test]
fn directory_size_bytes_sums_regular_files_recursively() {
let temp = tempfile::TempDir::new().expect("temp dir should be created");
let root = temp.path().join("cache");
std::fs::create_dir_all(root.join("node_modules/dep")).expect("tree should be created");
std::fs::write(root.join("a.txt"), vec![0u8; 10]).expect("file should be written");
std::fs::write(root.join("node_modules/dep/b.js"), vec![0u8; 32])
.expect("nested file should be written");
std::fs::write(root.join(".gitignore"), "node_modules\n")
.expect("gitignore should be written");
let size = directory_size_bytes(&root).expect("size walk should succeed");
assert_eq!(size, 10 + 32 + "node_modules\n".len() as u64);
assert_eq!(
directory_size_bytes(&temp.path().join("missing")),
None,
"an absent directory reports no size",
);
}
#[cfg(unix)]
#[test]
fn directory_size_bytes_never_follows_symlinks() {
let temp = tempfile::TempDir::new().expect("temp dir should be created");
let root = temp.path().join("cache");
let outside = temp.path().join("outside");
std::fs::create_dir_all(&root).expect("root should be created");
std::fs::create_dir_all(&outside).expect("outside dir should be created");
std::fs::write(outside.join("big.bin"), vec![0u8; 4096])
.expect("outside file should be written");
std::os::unix::fs::symlink(&outside, root.join("link-dir"))
.expect("dir symlink should be created");
std::os::unix::fs::symlink(outside.join("big.bin"), root.join("link-file"))
.expect("file symlink should be created");
assert_eq!(
directory_size_bytes(&root),
Some(0),
"symlinked directories and files must not be traversed or counted",
);
}
#[cfg(unix)]
#[test]
fn cache_sidecar_open_does_not_follow_symlinks() {
let temp = tempfile::TempDir::new().expect("temp dir should be created");
let victim = temp.path().join("victim");
let sidecar = temp.path().join("cache.lock");
std::fs::write(&victim, "unchanged\n").expect("victim should be written");
std::os::unix::fs::symlink(&victim, &sidecar).expect("sidecar symlink should be created");
assert!(open_or_create_owned_sidecar(&sidecar).is_err());
assert_eq!(
std::fs::read_to_string(victim).expect("victim should remain readable"),
"unchanged\n",
);
}
}