Skip to main content

fallow_cli/
base_worktree.rs

1use std::io::{Read, Write};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{Duration, SystemTime};
6
7use fallow_engine::changed_files::clear_ambient_git_env;
8pub use fallow_engine::repo_refs::materialize_base_dependency_context;
9use rustc_hash::{FxHashMap, FxHashSet};
10use xxhash_rust::xxh3::xxh3_64;
11
12use crate::report::plural;
13
14pub struct BaseWorktree {
15    path: PathBuf,
16    persistent: bool,
17    _reusable_lock: Option<ReusableWorktreeLock>,
18}
19
20impl BaseWorktree {
21    pub fn create(repo_root: &Path, base_ref: &str, base_sha: Option<&str>) -> Option<Self> {
22        sweep_orphan_audit_worktrees(repo_root);
23        if let Some(base_sha) = base_sha
24            && let Some(worktree) = Self::reuse_or_create(repo_root, base_sha)
25        {
26            return Some(worktree);
27        }
28        let path = non_reusable_worktree_path()?;
29        let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
30        if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
31            repo_root,
32            guard.path(),
33            base_ref,
34        ) {
35            tracing::debug!(
36                base_ref,
37                error = %error,
38                "could not materialize non-reusable audit base worktree",
39            );
40            return None;
41        }
42        // Deregister immediately so a crash before Drop never leaves a git
43        // worktree admin entry behind (issue #1815). No early return runs
44        // between here and the struct binding, so defusing the guard next is
45        // safe: the entry is already unregistered.
46        if let Err(error) = unregister_worktree(repo_root, guard.path()) {
47            tracing::debug!(
48                path = %guard.path().display(),
49                error = %error,
50                "could not deregister non-reusable audit base worktree",
51            );
52            return None;
53        }
54        guard.defuse();
55        drop(guard);
56        let worktree = Self {
57            path,
58            persistent: false,
59            _reusable_lock: None,
60        };
61        materialize_base_dependency_context(repo_root, worktree.path());
62        Some(worktree)
63    }
64
65    pub fn reuse_or_create(repo_root: &Path, base_sha: &str) -> Option<Self> {
66        let path = reusable_audit_worktree_path(repo_root);
67        let reusable_lock =
68            ReusableWorktreeLock::try_acquire(&path, "falling back to non-reusable worktree")?;
69
70        if reusable_audit_worktree_is_ready(&path, base_sha)
71            || try_migrate_registered_current_cache(repo_root, &path, base_sha)
72        {
73            let worktree = Self {
74                path,
75                persistent: true,
76                _reusable_lock: Some(reusable_lock),
77            };
78            materialize_base_dependency_context(repo_root, worktree.path());
79            record_last_used(worktree.path(), repo_root);
80            return Some(worktree);
81        }
82
83        if let Err(error) = remove_file_if_exists(&reusable_worktree_sha_path(&path)) {
84            tracing::debug!(
85                path = %path.display(),
86                error = %error,
87                "could not clear reusable audit worktree readiness before rebuild",
88            );
89            return None;
90        }
91        if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
92            tracing::debug!(
93                path = %path.display(),
94                error = %error,
95                "could not remove stale reusable audit worktree before rebuild",
96            );
97            return None;
98        }
99        let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
100        if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
101            repo_root,
102            guard.path(),
103            base_sha,
104        ) {
105            tracing::debug!(
106                base_sha,
107                error = %error,
108                "could not materialize reusable audit base worktree",
109            );
110            return None;
111        }
112        // Deregister while keeping the directory, then atomically publish the
113        // full base SHA through the `.sha` sidecar. Publication happens only
114        // after successful materialization and deregistration under the lock,
115        // so a torn snapshot is never advertised as ready to the next run.
116        if let Err(error) = unregister_worktree_checked(repo_root, guard.path()) {
117            tracing::debug!(
118                path = %guard.path().display(),
119                error = %error,
120                "could not deregister reusable audit base worktree",
121            );
122            return None;
123        }
124        guard.defuse();
125        drop(guard);
126        let readiness_published = write_reusable_sha(&path, base_sha).is_ok();
127
128        let worktree = Self {
129            path,
130            persistent: true,
131            _reusable_lock: Some(reusable_lock),
132        };
133        materialize_base_dependency_context(repo_root, worktree.path());
134        if readiness_published {
135            record_last_used(worktree.path(), repo_root);
136        }
137        Some(worktree)
138    }
139
140    pub fn path(&self) -> &Path {
141        &self.path
142    }
143}
144
145/// Build a unique temp path for a non-reusable base worktree.
146///
147/// The pid stays the FIRST `-`-separated segment so [`audit_worktree_pid`] and
148/// the orphan sweep keep working. A process-global monotonic counter is the
149/// final segment: the wall-clock nanos read is NOT monotonic and repeats across
150/// threads, so two `audit` runs in one process (e.g. parallel unit tests, or a
151/// future in-process batch) could otherwise mint the same path and race on
152/// `git worktree add`, where the loser fails and the audit aborts with a generic
153/// error. The counter makes every path distinct regardless of clock resolution.
154fn non_reusable_worktree_path() -> Option<PathBuf> {
155    static SEQ: AtomicU64 = AtomicU64::new(0);
156    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
157    let nanos = SystemTime::now()
158        .duration_since(SystemTime::UNIX_EPOCH)
159        .ok()?
160        .as_nanos();
161    Some(std::env::temp_dir().join(format!(
162        "fallow-audit-base-{}-{nanos}-{seq}",
163        std::process::id()
164    )))
165}
166
167/// RAII cleanup guard for a freshly-created git worktree directory.
168///
169/// Armed before the `git worktree add` subprocess runs. If the holder returns
170/// early (`?`) between subprocess success and the `BaseWorktree` struct binding,
171/// `Drop` rolls back BOTH git's `.git/worktrees/<name>` registration AND the
172/// on-disk directory. The owner calls `defuse()` once `BaseWorktree` is bound
173/// and takes over cleanup via its own `Drop`.
174///
175/// With `panic = "abort"` on the release profile, this does not provide
176/// panic-recovery cleanup (no unwind runs), but it is still load-bearing for
177/// every early-return path between subprocess success and struct construction.
178pub struct WorktreeCleanupGuard<'a> {
179    repo_root: PathBuf,
180    path: &'a Path,
181    armed: bool,
182}
183
184impl<'a> WorktreeCleanupGuard<'a> {
185    pub fn new(repo_root: &Path, path: &'a Path) -> Self {
186        Self {
187            repo_root: repo_root.to_path_buf(),
188            path,
189            armed: true,
190        }
191    }
192
193    pub fn path(&self) -> &Path {
194        self.path
195    }
196
197    /// Disarm in place. Idempotent; calling twice is harmless. Drop becomes a
198    /// no-op after this returns.
199    pub fn defuse(&mut self) {
200        self.armed = false;
201    }
202}
203
204impl Drop for WorktreeCleanupGuard<'_> {
205    fn drop(&mut self) {
206        if self.armed {
207            remove_audit_worktree(&self.repo_root, self.path);
208            let _ = std::fs::remove_dir_all(self.path);
209        }
210    }
211}
212
213/// Kernel-level advisory lock around the reusable-cache `reuse_or_create`
214/// critical section, backed by `std::fs::File::try_lock` (stable since Rust
215/// 1.89), which wraps `flock(2)` on Unix and `LockFileEx` on Windows.
216/// Concurrent acquirers either fall through (`None`) or observe a
217/// freshly-prepared cache after the holder releases.
218pub struct ReusableWorktreeLock {
219    file: std::fs::File,
220}
221
222impl ReusableWorktreeLock {
223    /// `context` names the caller's contention fallback in the debug
224    /// diagnostic ("falls back to a non-reusable worktree" is only true for
225    /// `reuse_or_create`; a GC sweep or prune skips the entry instead).
226    pub fn try_acquire(reusable_path: &Path, context: &'static str) -> Option<Self> {
227        let lock_path = reusable_worktree_lock_path(reusable_path);
228        let file = open_or_create_owned_sidecar(&lock_path).ok()?;
229        match file.try_lock() {
230            Ok(()) => Some(Self { file }),
231            Err(std::fs::TryLockError::WouldBlock) => {
232                tracing::debug!(
233                    path = %lock_path.display(),
234                    context,
235                    "reusable audit worktree lock contended",
236                );
237                None
238            }
239            Err(std::fs::TryLockError::Error(err)) => {
240                tracing::debug!(
241                    path = %lock_path.display(),
242                    error = %err,
243                    context,
244                    "could not acquire reusable audit worktree lock",
245                );
246                None
247            }
248        }
249    }
250}
251
252impl Drop for ReusableWorktreeLock {
253    fn drop(&mut self) {
254        let _ = self.file.unlock();
255    }
256}
257
258pub fn reusable_worktree_lock_path(reusable_path: &Path) -> PathBuf {
259    sidecar_path(reusable_path, REUSABLE_LOCK_SUFFIX)
260}
261
262/// Build a sidecar path `<cache dir name><suffix>` next to (NOT inside) the
263/// reusable cache directory, so the sidecar survives `git worktree`
264/// operations and directory materialization on the cache dir itself.
265fn sidecar_path(reusable_path: &Path, suffix: &str) -> PathBuf {
266    let mut name = reusable_path
267        .file_name()
268        .map(std::ffi::OsString::from)
269        .unwrap_or_default();
270    name.push(suffix);
271    reusable_path
272        .parent()
273        .map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
274}
275
276/// Sidecar path recording the base SHA a reusable cache entry was
277/// materialized at. Lives next to the cache directory like the `.last-used` /
278/// `.lock` sidecars, so readiness can be verified without a `git` subprocess.
279pub fn reusable_worktree_sha_path(reusable_path: &Path) -> PathBuf {
280    sidecar_path(reusable_path, REUSABLE_SHA_SUFFIX)
281}
282
283/// Record the base SHA a reusable cache holds. Failure is non-fatal: this run
284/// proceeds and the next run rebuilds (a missing `.sha` reads as not-ready).
285fn write_reusable_sha(reusable_path: &Path, base_sha: &str) -> std::io::Result<()> {
286    static SEQ: AtomicU64 = AtomicU64::new(0);
287
288    let sha_path = reusable_worktree_sha_path(reusable_path);
289    let sequence = SEQ.fetch_add(1, Ordering::Relaxed);
290    let temp_path = sidecar_path(
291        reusable_path,
292        &format!(
293            "{REUSABLE_SHA_SUFFIX}.tmp-{}-{sequence}",
294            std::process::id()
295        ),
296    );
297    let result = (|| {
298        let mut options = std::fs::OpenOptions::new();
299        options.create_new(true).write(true);
300        #[cfg(unix)]
301        {
302            use std::os::unix::fs::OpenOptionsExt as _;
303            options.mode(0o600);
304        }
305        let mut file = options.open(&temp_path)?;
306        file.write_all(format!("{base_sha}\n").as_bytes())?;
307        file.sync_all()?;
308        std::fs::rename(&temp_path, &sha_path)
309    })();
310    if let Err(err) = &result {
311        let _ = std::fs::remove_file(&temp_path);
312        tracing::debug!(
313            path = %sha_path.display(),
314            error = %err,
315            "failed to write reusable audit worktree .sha sidecar; next run will rebuild",
316        );
317    }
318    result
319}
320
321/// Default GC threshold for persistent reusable base-snapshot caches.
322const DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS: u32 = 30;
323
324const SECONDS_PER_DAY: u64 = 86_400;
325
326/// Env var that overrides `audit.cacheMaxAgeDays` from the config.
327const AUDIT_CACHE_MAX_AGE_ENV: &str = "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS";
328
329/// Sidecar filename suffix used to track last-use of a reusable worktree.
330const REUSABLE_LAST_USED_SUFFIX: &str = ".last-used";
331
332/// Sidecar filename suffix recording the base SHA a reusable cache holds.
333const REUSABLE_SHA_SUFFIX: &str = ".sha";
334
335/// Sidecar filename suffix of the reuse-lock file.
336const REUSABLE_LOCK_SUFFIX: &str = ".lock";
337
338/// Invalid gitdir pointer written into `<cache>/.git` after deregistering a
339/// transient audit worktree (issue #1815).
340///
341/// The `.git` file is REPLACED, never deleted: both discovery walkers use the
342/// `ignore` crate with `require_git` on, whose gitignore handling is gated on
343/// `<root>/.git` existing. Deleting the gitfile would silently stop the base
344/// pass from honoring `.gitignore`, inflating base findings and skewing
345/// audit's introduced-vs-inherited split. The stub keeps gitignore parity
346/// while pointing at a nonexistent gitdir, so any stray `git` command inside
347/// the snapshot fails loudly instead of operating on the host repo.
348const UNREGISTERED_GITDIR_STUB: &str = "gitdir: fallow-audit-unregistered\n";
349
350/// Sidecar path for the "last used" timestamp of a reusable cache entry.
351///
352/// Lives next to the cache directory (NOT inside it) so the sidecar is
353/// untouched by `git worktree add/remove` on the cache directory itself.
354pub fn reusable_worktree_last_used_path(reusable_path: &Path) -> PathBuf {
355    sidecar_path(reusable_path, REUSABLE_LAST_USED_SUFFIX)
356}
357
358/// Stamp the sidecar `.last-used` file's mtime to now.
359///
360/// Called on every cache-hit reuse (and from the pre-upgrade-grace branch
361/// of the GC sweep) so the staleness signal stays current even when the
362/// cache directory itself is not mutated. Failures are surfaced at
363/// `warn!` so a persistent ENOSPC / read-only-tmp condition is visible at
364/// default `RUST_LOG=warn`; the caller does not abort the audit.
365pub fn touch_last_used(reusable_path: &Path) {
366    stamp_last_used(reusable_path, None);
367}
368
369/// Stamp `.last-used` like [`touch_last_used`] and additionally record
370/// `owner_root` (the requested analysis root that owns this cache) as the
371/// sidecar's content. The cross-repo GC pass reads it back to decide whether
372/// an entry outside the current repo's scope is still owned by a live project
373/// (issue #2169): caches whose recorded owner no longer exists on disk are
374/// abandoned and become eligible for age-based reclaim from any repo's sweep.
375pub fn record_last_used(reusable_path: &Path, owner_root: &Path) {
376    stamp_last_used(reusable_path, Some(owner_root));
377}
378
379fn stamp_last_used(reusable_path: &Path, owner_root: Option<&Path>) {
380    let last_used = reusable_worktree_last_used_path(reusable_path);
381    let result = open_or_create_owned_sidecar(&last_used).and_then(|mut file| {
382        if let Some(owner_root) = owner_root {
383            file.set_len(0)?;
384            file.write_all(format!("{}\n", owner_root.display()).as_bytes())?;
385        }
386        file.set_modified(SystemTime::now())
387    });
388    if let Err(err) = result {
389        tracing::warn!(
390            path = %last_used.display(),
391            error = %err,
392            "failed to touch reusable audit worktree sidecar; staleness signal may not update",
393        );
394    }
395}
396
397/// Read the owner root recorded in a cache entry's `.last-used` sidecar.
398/// Returns `None` for missing, empty (pre-#2169), oversized, or non-file
399/// sidecars. The path is only ever used for an existence probe, never as a
400/// removal target, so untrusted content cannot redirect the sweep.
401fn read_last_used_owner(reusable_path: &Path) -> Option<PathBuf> {
402    const MAX_OWNER_SIDECAR_BYTES: u64 = 4096;
403
404    let sidecar = reusable_worktree_last_used_path(reusable_path);
405    let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
406    if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_OWNER_SIDECAR_BYTES {
407        return None;
408    }
409    let mut contents = String::new();
410    std::fs::File::open(sidecar)
411        .ok()?
412        .take(MAX_OWNER_SIDECAR_BYTES)
413        .read_to_string(&mut contents)
414        .ok()?;
415    let owner = contents.trim();
416    if owner.is_empty() {
417        return None;
418    }
419    Some(PathBuf::from(owner))
420}
421
422fn open_or_create_owned_sidecar(path: &Path) -> std::io::Result<std::fs::File> {
423    match std::fs::symlink_metadata(path) {
424        Ok(metadata) if sidecar_metadata_is_trusted(&metadata) => {
425            std::fs::OpenOptions::new().write(true).open(path)
426        }
427        Ok(_) => Err(std::io::Error::new(
428            std::io::ErrorKind::PermissionDenied,
429            "refusing to open an untrusted audit cache sidecar",
430        )),
431        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
432            let mut options = std::fs::OpenOptions::new();
433            options.create_new(true).write(true);
434            #[cfg(unix)]
435            {
436                use std::os::unix::fs::OpenOptionsExt as _;
437                options.mode(0o600);
438            }
439            options.open(path)
440        }
441        Err(error) => Err(error),
442    }
443}
444
445#[cfg(unix)]
446fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
447    use std::os::unix::fs::MetadataExt as _;
448
449    metadata_is_regular_file(metadata) && metadata.uid() == rustix::process::geteuid().as_raw()
450}
451
452#[cfg(not(unix))]
453fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
454    metadata_is_regular_file(metadata)
455}
456
457#[expect(
458    clippy::filetype_is_file,
459    reason = "security-sensitive sidecars and gitfiles must be regular files, not arbitrary non-directories"
460)]
461fn metadata_is_regular_file(metadata: &std::fs::Metadata) -> bool {
462    metadata.file_type().is_file()
463}
464
465/// Which precedence level supplied the effective cache GC threshold.
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467pub enum CacheMaxAgeSource {
468    Flag,
469    Env,
470    Config,
471    Default,
472}
473
474impl CacheMaxAgeSource {
475    #[must_use]
476    pub const fn as_str(self) -> &'static str {
477        match self {
478            Self::Flag => "flag",
479            Self::Env => "env",
480            Self::Config => "config",
481            Self::Default => "default",
482        }
483    }
484}
485
486/// Effective GC threshold plus the precedence level that supplied it.
487/// `max_age` is `None` when the winning value is `0` (age-based GC disabled).
488#[derive(Debug, Clone, Copy)]
489pub struct ResolvedCacheMaxAge {
490    pub max_age: Option<Duration>,
491    pub days: u32,
492    pub source: CacheMaxAgeSource,
493}
494
495/// Resolve the GC threshold for persistent reusable caches.
496///
497/// Precedence: `FALLOW_AUDIT_CACHE_MAX_AGE_DAYS` env var > `audit.cacheMaxAgeDays`
498/// config field > 30-day default. `0` from either source disables the sweep
499/// entirely (returns `None`). Invalid env values (non-integer) warn and fall
500/// back to config / default; audits do not fail on a typo in a runner env var.
501pub fn resolve_cache_max_age_with_options(
502    root: &Path,
503    config_path: Option<&PathBuf>,
504    allow_remote_extends: bool,
505) -> Option<Duration> {
506    resolve_cache_max_age_with_source(root, config_path, allow_remote_extends, None).max_age
507}
508
509/// [`resolve_cache_max_age_with_options`] with an optional per-invocation
510/// override (`fallow audit-cache prune --max-age-days`) that wins over every
511/// other level, and the winning source reported for display.
512pub fn resolve_cache_max_age_with_source(
513    root: &Path,
514    config_path: Option<&PathBuf>,
515    allow_remote_extends: bool,
516    flag_days: Option<u32>,
517) -> ResolvedCacheMaxAge {
518    if let Some(days) = flag_days {
519        return ResolvedCacheMaxAge {
520            max_age: days_to_duration(days),
521            days,
522            source: CacheMaxAgeSource::Flag,
523        };
524    }
525    if let Ok(raw) = std::env::var(AUDIT_CACHE_MAX_AGE_ENV) {
526        if let Ok(days) = raw.trim().parse::<u32>() {
527            return ResolvedCacheMaxAge {
528                max_age: days_to_duration(days),
529                days,
530                source: CacheMaxAgeSource::Env,
531            };
532        }
533        tracing::warn!(
534            value = %raw,
535            "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS is not a valid u32; falling back to config/default",
536        );
537    }
538    if let Some(days) = load_audit_config(root, config_path, allow_remote_extends)
539        .and_then(|c| c.cache_max_age_days)
540    {
541        return ResolvedCacheMaxAge {
542            max_age: days_to_duration(days),
543            days,
544            source: CacheMaxAgeSource::Config,
545        };
546    }
547    ResolvedCacheMaxAge {
548        max_age: days_to_duration(DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS),
549        days: DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS,
550        source: CacheMaxAgeSource::Default,
551    }
552}
553
554pub fn days_to_duration(days: u32) -> Option<Duration> {
555    if days == 0 {
556        return None;
557    }
558    Some(Duration::from_secs(u64::from(days) * SECONDS_PER_DAY))
559}
560
561/// Load `AuditConfig` from `opts.config_path` (or auto-discover from
562/// `opts.root`) for GC-threshold resolution only. Errors silently fall
563/// back to `None`; the caller defaults to a 30-day window.
564fn load_audit_config(
565    root: &Path,
566    config_path: Option<&PathBuf>,
567    allow_remote_extends: bool,
568) -> Option<fallow_config::AuditConfig> {
569    let options = fallow_config::ConfigLoadOptions {
570        allow_remote_extends,
571    };
572    if let Some(path) = config_path {
573        return fallow_config::FallowConfig::load_with_options(path, options)
574            .ok()
575            .map(|config| config.audit);
576    }
577    fallow_config::FallowConfig::find_and_load_with_options(root, options)
578        .ok()
579        .flatten()
580        .map(|(config, _path)| config.audit)
581}
582
583/// Reclaim persistent reusable base-snapshot worktree caches.
584///
585/// Two reclaim conditions, checked per entry:
586/// - Prunable orphan: the cache directory no longer exists (an external
587///   `$TMPDIR` reaper, a container restart, or a CI cache eviction deleted it
588///   but left git's admin entry behind). Reclaimed eagerly, independent of
589///   `max_age`, because the `.last-used` sidecar lives next to the deleted
590///   directory and survives the reaper, so the age branch would re-touch a
591///   fresh sidecar and never reclaim the dead entry. Passing `max_age = None`
592///   (age-based GC disabled) still runs this reclaim.
593/// - Aged-out: the sidecar `.last-used` file is older than `max_age` (only
594///   when `max_age` is `Some`).
595///
596/// Entries under other repo hashes (other linked worktrees, deleted or moved
597/// repos) are visited by a cross-repo pass; see
598/// [`reclaim_foreign_cache_entry`] for its owner-liveness gate (issue #2169).
599///
600/// Concurrency: each candidate is gated by [`ReusableWorktreeLock`] before
601/// removal, so an in-flight `fallow audit` mid-rebuild against the same
602/// cache entry will not be disturbed (the sweep skips on contention). The
603/// orphan branch re-checks existence under the lock so a rebuild that
604/// recreated the directory between the check and the lock is preserved.
605///
606/// Pre-upgrade caches lacking a sidecar are NOT removed: instead the sweep
607/// seeds a fresh sidecar so the next invocation can age them from real
608/// last-use. Without this grace, the dir's own mtime (= creation date on
609/// POSIX) would wipe every legitimately-warm pre-upgrade cache on the
610/// first run after upgrade.
611///
612/// The `.lock` sidecar file is intentionally NOT deleted on removal: a
613/// racing acquirer of an unlinked-but-still-flocked inode plus a sibling
614/// `open(O_CREAT)` at the same path would produce two processes each
615/// holding a kernel flock on different inodes. Lock files are tens of
616/// bytes; leaking them is harmless.
617pub fn sweep_old_reusable_caches(repo_root: &Path, max_age: Option<Duration>, quiet: bool) {
618    sweep_old_reusable_caches_in(repo_root, max_age, quiet, &std::env::temp_dir());
619}
620
621/// Scan-root-injectable body of [`sweep_old_reusable_caches`]. Production
622/// always passes `std::env::temp_dir()`; tests pass a private per-test
623/// directory so one test's cross-repo pass never scans (and reclaims)
624/// another test's fixtures or a developer's real ownerless caches in the
625/// shared temp dir.
626pub fn sweep_old_reusable_caches_in(
627    repo_root: &Path,
628    max_age: Option<Duration>,
629    quiet: bool,
630    scan_root: &Path,
631) {
632    let report = sweep_reusable_caches_with_report(
633        repo_root,
634        max_age,
635        scan_root,
636        SweepMode::Apply,
637        SweepSizes::Skip,
638    );
639    log_sweep_entries(&report, SweepMode::Apply, max_age);
640    let removed = report.removed;
641    if removed == 0 {
642        return;
643    }
644    tracing::info!(
645        count = removed,
646        "reclaimed stale audit base-snapshot caches",
647    );
648    if !quiet {
649        let s = plural(removed as usize);
650        let _ = writeln!(
651            std::io::stderr(),
652            "fallow: reclaimed {removed} stale base-snapshot cache{s}",
653        );
654    }
655}
656
657/// How a cache sweep executes.
658#[derive(Debug, Clone, Copy, PartialEq, Eq)]
659pub enum SweepMode {
660    /// Normal GC: acquire locks, seed grace sidecars, remove entries.
661    Apply,
662    /// Preview: dispositions are computed from read-only probes only. No lock
663    /// acquisition (`try_acquire` would create `.lock` sidecars), no grace
664    /// seeding, no removal, no `git` worktree deregistration or prune.
665    DryRun,
666}
667
668impl SweepMode {
669    #[must_use]
670    pub const fn as_str(self) -> &'static str {
671        match self {
672            Self::Apply => "apply",
673            Self::DryRun => "dry-run",
674        }
675    }
676}
677
678/// Whether the sweep measures per-entry directory sizes. Measuring walks the
679/// full cache tree, so only the manual prune command asks for it; the
680/// per-audit sweep stays IO-light.
681#[derive(Debug, Clone, Copy, PartialEq, Eq)]
682pub enum SweepSizes {
683    Skip,
684    Measure,
685}
686
687/// Which enumeration pass produced a sweep candidate.
688#[derive(Debug, Clone, Copy, PartialEq, Eq)]
689pub enum SweepPass {
690    /// The requested root's own cache entry.
691    Owned,
692    /// Old SHA-suffixed entries under this repo's hash.
693    Legacy,
694    /// Entries under other repo hashes (cross-repo pass, issue #2169).
695    Foreign,
696}
697
698impl SweepPass {
699    #[must_use]
700    pub const fn as_str(self) -> &'static str {
701        match self {
702            Self::Owned => "owned",
703            Self::Legacy => "legacy",
704            Self::Foreign => "foreign",
705        }
706    }
707}
708
709/// Decision bucket of a [`SweepDisposition`], the closed `disposition` set of
710/// the prune JSON envelope.
711#[derive(Debug, Clone, Copy, PartialEq, Eq)]
712pub enum SweepDecision {
713    Removed,
714    Kept,
715    Skipped,
716    Failed,
717}
718
719impl SweepDecision {
720    #[must_use]
721    pub const fn as_str(self) -> &'static str {
722        match self {
723            Self::Removed => "removed",
724            Self::Kept => "kept",
725            Self::Skipped => "skipped",
726            Self::Failed => "failed",
727        }
728    }
729}
730
731/// Per-entry outcome of one sweep candidate.
732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
733pub enum SweepDisposition {
734    /// Sidecars of a cache directory an external reaper already deleted.
735    ReclaimedOrphan,
736    /// Entry aged past the effective threshold.
737    ReclaimedAged,
738    /// Foreign entry whose recorded owner root is gone, reclaimed while
739    /// age-based GC is disabled (`cacheMaxAgeDays = 0`). Nothing will ever
740    /// sweep it under its own hash again, so the age gate that `0` switches
741    /// off would otherwise strand it on disk forever.
742    ReclaimedOwnerMissing,
743    /// A released SHA-keyed directory still REGISTERED as a git worktree by
744    /// pre-#1815 fallow; the root-owned cache cannot reuse it, so it is
745    /// deregistered AND removed.
746    ReclaimedLegacyRegistered,
747    /// The current cache path still REGISTERED as a git worktree by pre-#1815
748    /// fallow (a mixed-version registration); only the git registration is
749    /// reclaimed and the directory stays warm on disk, so its measured size
750    /// never counts as reclaimed bytes.
751    KeptLegacyDeregistered,
752    /// Younger than the threshold.
753    KeptFresh,
754    /// Foreign entry whose recorded owner root still exists; that repo's own
755    /// sweep governs it.
756    KeptOwnerLive,
757    /// Foreign entry whose owner probe failed with a non-NotFound error; kept
758    /// so a transient probe failure can never reclaim a live repo's cache.
759    KeptOwnerUnverifiable(std::io::ErrorKind),
760    /// Age-based GC disabled (`cacheMaxAgeDays = 0`) and the entry is not an
761    /// orphan.
762    KeptAgeGcDisabled,
763    /// First encounter of a pre-upgrade entry without a `.last-used` sidecar;
764    /// a sidecar is seeded so it ages from real last-use.
765    KeptGraceSeeded,
766    /// Only a `.lock` sidecar survives (never deleted by design); there is no
767    /// directory, `.sha`, or `.last-used` left to reclaim.
768    KeptLockOnly,
769    /// The orphan re-check under the lock found the directory rebuilt by a
770    /// concurrent run.
771    KeptRecreated,
772    /// The Unix uid ownership check rejects removal (another user's entry on
773    /// a shared runner).
774    KeptNotOwned,
775    /// Lock contention; a concurrent fallow holds this entry.
776    SkippedLocked,
777    /// A genuine IO error after the ownership check passed.
778    RemoveFailed,
779}
780
781impl SweepDisposition {
782    #[must_use]
783    pub const fn decision(self) -> SweepDecision {
784        match self {
785            Self::ReclaimedOrphan
786            | Self::ReclaimedAged
787            | Self::ReclaimedOwnerMissing
788            | Self::ReclaimedLegacyRegistered => SweepDecision::Removed,
789            Self::KeptLegacyDeregistered
790            | Self::KeptFresh
791            | Self::KeptOwnerLive
792            | Self::KeptOwnerUnverifiable(_)
793            | Self::KeptAgeGcDisabled
794            | Self::KeptGraceSeeded
795            | Self::KeptLockOnly
796            | Self::KeptRecreated
797            | Self::KeptNotOwned => SweepDecision::Kept,
798            Self::SkippedLocked => SweepDecision::Skipped,
799            Self::RemoveFailed => SweepDecision::Failed,
800        }
801    }
802
803    /// Closed `reason` vocabulary of the prune JSON envelope and the debug
804    /// diagnostics.
805    #[must_use]
806    pub const fn reason(self) -> &'static str {
807        match self {
808            Self::ReclaimedOrphan => "orphaned-sidecars",
809            Self::ReclaimedAged => "aged-out",
810            Self::ReclaimedOwnerMissing => "owner-missing",
811            Self::ReclaimedLegacyRegistered => "legacy-registered",
812            Self::KeptLegacyDeregistered => "legacy-deregistered",
813            Self::KeptFresh => "fresh",
814            Self::KeptOwnerLive => "owner-live",
815            Self::KeptOwnerUnverifiable(_) => "owner-unverifiable",
816            Self::KeptAgeGcDisabled => "age-gc-disabled",
817            Self::KeptGraceSeeded => "grace-seeded",
818            Self::KeptLockOnly => "lock-only",
819            Self::KeptRecreated => "recreated",
820            Self::KeptNotOwned => "not-owned",
821            Self::SkippedLocked => "lock-contention",
822            Self::RemoveFailed => "remove-failed",
823        }
824    }
825
826    /// Whether this disposition increments the legacy `removed` counter used
827    /// by the per-audit stderr summary. Legacy-registered reclaims never did.
828    const fn counts_toward_summary(self) -> bool {
829        matches!(
830            self,
831            Self::ReclaimedOrphan | Self::ReclaimedAged | Self::ReclaimedOwnerMissing
832        )
833    }
834}
835
836/// One considered candidate of a cache sweep.
837#[derive(Debug, Clone)]
838pub struct SweepEntry {
839    pub path: PathBuf,
840    pub pass: SweepPass,
841    pub disposition: SweepDisposition,
842    /// Whole days since the `.last-used` sidecar mtime, when readable.
843    pub age_days: Option<u64>,
844    /// Owner root recorded in the `.last-used` sidecar, when present.
845    pub owner_root: Option<PathBuf>,
846    /// Recursive directory size, measured only under [`SweepSizes::Measure`]
847    /// and `None` when the directory is absent or unreadable.
848    pub size_bytes: Option<u64>,
849}
850
851/// Full result of one sweep. `removed` keeps the pre-#2221 counter semantics
852/// (orphan and aged reclaims from the owned/legacy and cross-repo passes) so
853/// the audit stderr summary is unchanged.
854#[derive(Debug, Default)]
855pub struct SweepReport {
856    pub entries: Vec<SweepEntry>,
857    pub removed: u32,
858}
859
860impl SweepReport {
861    fn push(&mut self, entry: SweepEntry) {
862        if entry.disposition.counts_toward_summary() {
863            self.removed += 1;
864        }
865        self.entries.push(entry);
866    }
867}
868
869const GC_LOCK_CONTEXT: &str = "gc sweep skips the entry";
870
871/// Emit one `tracing::debug!` line per considered entry. Shared by the
872/// implicit per-audit sweep and `fallow audit-cache prune`, so both surfaces
873/// produce identical diagnostics under an explicit `RUST_LOG` directive.
874pub fn log_sweep_entries(report: &SweepReport, mode: SweepMode, max_age: Option<Duration>) {
875    let threshold_days = max_age.map(|age| age.as_secs() / SECONDS_PER_DAY);
876    for entry in &report.entries {
877        let owner_probe_error = match entry.disposition {
878            SweepDisposition::KeptOwnerUnverifiable(kind) => Some(kind),
879            _ => None,
880        };
881        tracing::debug!(
882            path = %entry.path.display(),
883            pass = entry.pass.as_str(),
884            mode = mode.as_str(),
885            decision = entry.disposition.decision().as_str(),
886            reason = entry.disposition.reason(),
887            age_days = entry.age_days,
888            threshold_days,
889            owner_root = entry
890                .owner_root
891                .as_ref()
892                .map(|owner| tracing::field::display(owner.display())),
893            owner_probe_error = owner_probe_error.map(tracing::field::debug),
894            "audit cache sweep considered entry",
895        );
896    }
897}
898
899/// Report-producing body shared by the per-audit GC sweep
900/// ([`sweep_old_reusable_caches_in`], [`SweepMode::Apply`], sizes skipped)
901/// and `fallow audit-cache prune` (both modes, sizes measured). Running the
902/// same code path is what guarantees prune "runs the same reclaim logic"
903/// (issue #2221) by construction.
904pub fn sweep_reusable_caches_with_report(
905    repo_root: &Path,
906    max_age: Option<Duration>,
907    scan_root: &Path,
908    mode: SweepMode,
909    sizes: SweepSizes,
910) -> SweepReport {
911    let now = SystemTime::now();
912    let mut report = SweepReport::default();
913
914    // Legacy pass: deregister reusable caches left REGISTERED by pre-#1815
915    // fallow (the reporter's `git worktree list` backlog). This is what makes
916    // those entries vanish on the first post-upgrade audit; the `--expire=now`
917    // prune is retained ONLY here to also sweep any admin entry orphaned by a
918    // crash in the transient registration window.
919    legacy_registered_pass(repo_root, mode, sizes, now, &mut report);
920
921    // Primary pass: visit this requested root's cache plus legacy SHA-suffixed
922    // entries from the old git-top-level identity. `git worktree list` no
923    // longer sees the deregistered caches.
924    let owned_path = reusable_audit_worktree_path(repo_root);
925    let mut paths = vec![owned_path.clone()];
926    paths.extend(scan_legacy_reusable_cache_paths(repo_root, scan_root));
927    paths.sort();
928    paths.dedup();
929
930    // Cross-repo pass (issue #2169): every other repo hash accumulates caches
931    // the repo-scoped pass can never see (one per linked git worktree, plus
932    // entries from deleted or moved repos that no surviving sweep covers).
933    // Entries whose recorded owner root still exists are left to that repo's
934    // own sweep, so its `cacheMaxAgeDays` setting, including `0`, governs;
935    // the rest are abandoned and age out under this run's threshold.
936    let scoped: FxHashSet<&PathBuf> = paths.iter().collect();
937    let foreign: Vec<PathBuf> = scan_all_reusable_cache_paths(scan_root)
938        .into_iter()
939        .filter(|path| !scoped.contains(path))
940        .collect();
941    drop(scoped);
942
943    // Sizes are measured BEFORE the decision pass so removed entries still
944    // report what they occupied.
945    let mut size_map = measure_entry_sizes(sizes, paths.iter().chain(foreign.iter()));
946
947    for path in &paths {
948        // The requested root's path is a synthetic candidate (always added);
949        // when nothing exists for it on disk (no directory, sidecars, or
950        // `.lock`) there is nothing to consider or report, and probing it
951        // under `Apply` would mint a `.lock` sidecar for a cache that was
952        // never built. Scan-derived candidates always have some presence.
953        if !cache_entry_has_presence(path) {
954            continue;
955        }
956        let pass = if *path == owned_path {
957            SweepPass::Owned
958        } else {
959            SweepPass::Legacy
960        };
961        let (age_days, owner_root) = entry_probe_metadata(path, now);
962        let disposition = match mode {
963            SweepMode::Apply => reclaim_reusable_cache_entry(repo_root, path, max_age, now),
964            SweepMode::DryRun => classify_entry_dry_run(path, max_age, now, OwnerGate::Off),
965        };
966        report.push(SweepEntry {
967            path: path.clone(),
968            pass,
969            disposition,
970            age_days,
971            owner_root,
972            size_bytes: size_map.remove(path).flatten(),
973        });
974    }
975    for path in &foreign {
976        let (age_days, owner_root) = entry_probe_metadata(path, now);
977        let disposition = match mode {
978            SweepMode::Apply => reclaim_foreign_cache_entry(repo_root, path, max_age, now),
979            SweepMode::DryRun => classify_entry_dry_run(path, max_age, now, OwnerGate::On),
980        };
981        report.push(SweepEntry {
982            path: path.clone(),
983            pass: SweepPass::Foreign,
984            disposition,
985            age_days,
986            owner_root,
987            size_bytes: size_map.remove(path).flatten(),
988        });
989    }
990    report
991}
992
993/// Read-only per-entry metadata for reporting, gathered BEFORE the decision
994/// pass may remove the sidecars it comes from.
995fn entry_probe_metadata(path: &Path, now: SystemTime) -> (Option<u64>, Option<PathBuf>) {
996    let age_days = last_used_mtime(path)
997        .and_then(|mtime| now.duration_since(mtime).ok())
998        .map(|age| age.as_secs() / SECONDS_PER_DAY);
999    (age_days, read_last_used_owner(path))
1000}
1001
1002/// Deregister reusable base-snapshot caches left REGISTERED by fallow versions
1003/// before #1815. A mixed-version registration at the current path stays warm
1004/// and reports [`SweepDisposition::KeptLegacyDeregistered`] (only the git
1005/// registration goes away, so nothing is reclaimed); released SHA-keyed paths
1006/// are removed because the root-owned cache cannot reuse them. Under
1007/// [`SweepMode::DryRun`] only the read-only halves run
1008/// (`list_audit_worktrees`, `audit_worktree_is_registered`): registered
1009/// entries are reported without locks, deregistration, removal, or the
1010/// trailing `git worktree prune`.
1011fn legacy_registered_pass(
1012    repo_root: &Path,
1013    mode: SweepMode,
1014    sizes: SweepSizes,
1015    now: SystemTime,
1016    report: &mut SweepReport,
1017) {
1018    let Some(worktrees) = list_audit_worktrees(repo_root) else {
1019        return;
1020    };
1021    let candidates: Vec<PathBuf> = worktrees
1022        .into_iter()
1023        .filter(|path| is_reusable_audit_worktree_path(path))
1024        .collect();
1025    let mut size_map = measure_entry_sizes(sizes, candidates.iter());
1026    let owned_path = reusable_audit_worktree_path(repo_root);
1027    let mut deregistered = false;
1028    for path in candidates {
1029        let pass = if paths_equal(&path, &owned_path) {
1030            SweepPass::Owned
1031        } else {
1032            SweepPass::Legacy
1033        };
1034        let (age_days, owner_root) = entry_probe_metadata(&path, now);
1035        let size_bytes = size_map.remove(&path).flatten();
1036        let is_current_path = pass == SweepPass::Owned;
1037        let disposition = match mode {
1038            SweepMode::DryRun => {
1039                if !audit_worktree_is_registered(repo_root, &path) {
1040                    continue;
1041                }
1042                if is_current_path {
1043                    SweepDisposition::KeptLegacyDeregistered
1044                } else {
1045                    SweepDisposition::ReclaimedLegacyRegistered
1046                }
1047            }
1048            SweepMode::Apply => {
1049                let Some(_lock) = ReusableWorktreeLock::try_acquire(
1050                    &path,
1051                    "legacy deregistration skips the entry",
1052                ) else {
1053                    report.push(SweepEntry {
1054                        path,
1055                        pass,
1056                        disposition: SweepDisposition::SkippedLocked,
1057                        age_days,
1058                        owner_root,
1059                        size_bytes,
1060                    });
1061                    continue;
1062                };
1063                if !audit_worktree_is_registered(repo_root, &path) {
1064                    continue;
1065                }
1066                let head = is_current_path
1067                    .then(|| legacy_reusable_sha(&path))
1068                    .flatten();
1069                if unregister_worktree_checked(repo_root, &path).is_err() {
1070                    report.push(SweepEntry {
1071                        path,
1072                        pass,
1073                        disposition: SweepDisposition::RemoveFailed,
1074                        age_days,
1075                        owner_root,
1076                        size_bytes,
1077                    });
1078                    continue;
1079                }
1080                let disposition = if is_current_path {
1081                    if let Some(head) = head {
1082                        let _ = write_reusable_sha(&path, &head);
1083                    }
1084                    SweepDisposition::KeptLegacyDeregistered
1085                } else if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
1086                    tracing::warn!(
1087                        path = %path.display(),
1088                        error = %error,
1089                        "failed to remove released SHA-keyed audit cache",
1090                    );
1091                    SweepDisposition::RemoveFailed
1092                } else {
1093                    SweepDisposition::ReclaimedLegacyRegistered
1094                };
1095                deregistered = true;
1096                disposition
1097            }
1098        };
1099        report.push(SweepEntry {
1100            path,
1101            pass,
1102            disposition,
1103            age_days,
1104            owner_root,
1105            size_bytes,
1106        });
1107    }
1108    if deregistered {
1109        let mut command = Command::new("git");
1110        command
1111            .args(["worktree", "prune", "--expire=now"])
1112            .current_dir(repo_root);
1113        clear_ambient_git_env(&mut command);
1114        let _ = command.output();
1115    }
1116}
1117
1118/// Seed the `.sha` sidecar for a still-registered legacy cache from its HEAD,
1119/// so after deregistration the readiness probe recognizes it as warm. Seeds
1120/// only when the snapshot was raw-materialized and no `.sha` exists yet.
1121fn legacy_reusable_sha(path: &Path) -> Option<String> {
1122    if reusable_worktree_sha_path(path).exists()
1123        || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
1124    {
1125        return None;
1126    }
1127    git_rev_parse(path, "HEAD")
1128}
1129
1130/// Enumerate reusable cache DIRECTORY paths for `prefix` by scanning the temp
1131/// dir. Sidecar entries (`.last-used` / `.sha` / `.lock`) are folded back to
1132/// their owning cache path and deduplicated, so a dir removed out from under
1133/// its sidecars is still visited for sidecar-orphan cleanup.
1134fn scan_legacy_reusable_cache_paths(repo_root: &Path, scan_root: &Path) -> Vec<PathBuf> {
1135    let Some(prefix) = legacy_reusable_cache_repo_prefix(repo_root) else {
1136        return Vec::new();
1137    };
1138    scan_cache_paths_with_hex_suffix(&prefix, scan_root)
1139}
1140
1141fn scan_root_owned_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
1142    let Some(prefix) = root_owned_cache_repo_prefix(repo_root) else {
1143        return Vec::new();
1144    };
1145    scan_cache_paths_with_hex_suffix(&prefix, &std::env::temp_dir())
1146}
1147
1148/// Enumerate every reusable cache entry in the temp dir, regardless of repo
1149/// hash, for the cross-repo GC pass. Only names matching the two shapes
1150/// fallow has ever minted are accepted: `<repo16>-<sha16>` (legacy) and
1151/// `<repo16>-root-<root16>` (current). Sidecar entries fold back to their
1152/// owning cache path like the repo-scoped scan.
1153fn scan_all_reusable_cache_paths(scan_root: &Path) -> Vec<PathBuf> {
1154    const GLOBAL_CACHE_PREFIX: &str = "fallow-audit-base-cache-";
1155
1156    let Ok(entries) = std::fs::read_dir(scan_root) else {
1157        return Vec::new();
1158    };
1159    let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
1160    let mut paths = Vec::new();
1161    for entry in entries.flatten() {
1162        let name = entry.file_name();
1163        let Some(name) = name.to_str() else {
1164            continue;
1165        };
1166        let cache_name = strip_cache_sidecar_suffix(name);
1167        let Some(hash_suffix) = cache_name.strip_prefix(GLOBAL_CACHE_PREFIX) else {
1168            continue;
1169        };
1170        if !cache_hash_suffix_is_valid(hash_suffix) {
1171            continue;
1172        }
1173        let path = scan_root.join(cache_name);
1174        if seen.insert(path.clone()) {
1175            paths.push(path);
1176        }
1177    }
1178    paths
1179}
1180
1181fn cache_hash_suffix_is_valid(suffix: &str) -> bool {
1182    fn is_hex16(part: &str) -> bool {
1183        part.len() == 16 && part.bytes().all(|byte| byte.is_ascii_hexdigit())
1184    }
1185    if let Some((repo, root)) = suffix.split_once("-root-") {
1186        return is_hex16(repo) && is_hex16(root);
1187    }
1188    suffix
1189        .split_once('-')
1190        .is_some_and(|(repo, sha)| is_hex16(repo) && is_hex16(sha))
1191}
1192
1193fn scan_cache_paths_with_hex_suffix(prefix: &str, scan_root: &Path) -> Vec<PathBuf> {
1194    let Ok(entries) = std::fs::read_dir(scan_root) else {
1195        return Vec::new();
1196    };
1197    let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
1198    let mut paths = Vec::new();
1199    for entry in entries.flatten() {
1200        let name = entry.file_name();
1201        let Some(name) = name.to_str() else {
1202            continue;
1203        };
1204        let cache_name = strip_cache_sidecar_suffix(name);
1205        let Some(hash_suffix) = cache_name.strip_prefix(prefix) else {
1206            continue;
1207        };
1208        if hash_suffix.len() != 16 || !hash_suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1209            continue;
1210        }
1211        let path = scan_root.join(cache_name);
1212        if seen.insert(path.clone()) {
1213            paths.push(path);
1214        }
1215    }
1216    paths
1217}
1218
1219/// Strip a known reusable-cache sidecar suffix so a sidecar entry maps to its
1220/// owning cache directory name. Cache dir names end in a hex SHA prefix and
1221/// never contain these suffixes, so the mapping is unambiguous.
1222fn strip_cache_sidecar_suffix(name: &str) -> &str {
1223    for suffix in [
1224        REUSABLE_LAST_USED_SUFFIX,
1225        REUSABLE_SHA_SUFFIX,
1226        REUSABLE_LOCK_SUFFIX,
1227    ] {
1228        if let Some(stripped) = name.strip_suffix(suffix) {
1229            return stripped;
1230        }
1231    }
1232    name
1233}
1234
1235/// Reclaim a single reusable-cache entry, reporting the per-entry outcome.
1236fn reclaim_reusable_cache_entry(
1237    repo_root: &Path,
1238    path: &Path,
1239    max_age: Option<Duration>,
1240    now: SystemTime,
1241) -> SweepDisposition {
1242    // Sidecar orphan: an external temp-reaper (macOS `$TMPDIR` cleanup,
1243    // container restart, CI cache eviction) removed the cache directory but
1244    // its sidecars survive next to it. Reclaim the leftover `.last-used` /
1245    // `.sha` eagerly, independent of `max_age`, so orphans do not accumulate
1246    // even when age-based GC is disabled (`cacheMaxAgeDays = 0`). This also
1247    // fixes a leak that predates #1815: a manual `git worktree remove` left
1248    // sidecars the old git-scoped sweep could never see.
1249    //
1250    // A probe error here reads as "exists" and falls through to the age
1251    // branch; failing toward keeping the entry is a cheap self-rebuild at
1252    // worst, and the orphan branch re-checks under the lock anyway.
1253    if !path.exists() {
1254        return reclaim_orphan_cache_entry(repo_root, path);
1255    }
1256    let Some(max_age) = max_age else {
1257        return SweepDisposition::KeptAgeGcDisabled;
1258    };
1259    reclaim_aged_cache_entry(repo_root, path, max_age, now)
1260}
1261
1262/// Reclaim the leftover sidecars of a cache directory that was deleted out
1263/// from under them. Lock-guarded with a re-check so a concurrent rebuild that
1264/// recreated the directory is preserved. The `.lock` sidecar is deliberately
1265/// never removed (an unlinked-but-still-flocked inode plus a racer's
1266/// `open(O_CREAT)` would split the lock across two inodes).
1267pub fn reclaim_orphan_cache_entry(repo_root: &Path, path: &Path) -> SweepDisposition {
1268    let Some(_lock) = ReusableWorktreeLock::try_acquire(path, GC_LOCK_CONTEXT) else {
1269        return SweepDisposition::SkippedLocked;
1270    };
1271    // Re-check under the lock: a concurrent `reuse_or_create` rebuild may
1272    // have recreated the directory between the existence check and the lock.
1273    if path.exists() {
1274        return SweepDisposition::KeptRecreated;
1275    }
1276    match remove_cache_entry_for_sweep(repo_root, path) {
1277        Ok(CacheRemovalOutcome::Removed) => SweepDisposition::ReclaimedOrphan,
1278        Ok(CacheRemovalOutcome::NothingLeft) => SweepDisposition::KeptLockOnly,
1279        Ok(CacheRemovalOutcome::NotOwned) => SweepDisposition::KeptNotOwned,
1280        Err(_) => SweepDisposition::RemoveFailed,
1281    }
1282}
1283
1284/// Reclaim a cache entry whose `.last-used` sidecar is older than `max_age`.
1285/// Seeds a fresh owner-recording sidecar for pre-upgrade entries that lack
1286/// one (grace-kept so they age from real last-use on the next run).
1287/// Removal is directory-and-sidecars only: the entry is unregistered, so no
1288/// `git` subprocess is involved.
1289fn reclaim_aged_cache_entry(
1290    repo_root: &Path,
1291    path: &Path,
1292    max_age: Duration,
1293    now: SystemTime,
1294) -> SweepDisposition {
1295    let Some(mtime) = last_used_mtime(path) else {
1296        record_last_used(path, repo_root);
1297        return SweepDisposition::KeptGraceSeeded;
1298    };
1299    remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
1300}
1301
1302/// Reclaim a cache entry that belongs to a DIFFERENT repo hash than the
1303/// sweeping repo (issue #2169).
1304///
1305/// An entry whose recorded owner root still exists on disk is skipped
1306/// unconditionally: that repo's own sweep governs it, so its
1307/// `cacheMaxAgeDays` policy (including `0` = never) cannot be defeated from
1308/// the outside. Entries with a dead owner, or with no recorded owner (pre-
1309/// upgrade or externally created), are abandoned: nothing will ever sweep
1310/// them under their own hash again, so they age out under THIS run's
1311/// threshold, and a probed-dead owner is reclaimed outright when that
1312/// threshold is switched off (`cacheMaxAgeDays = 0`), which is the documented
1313/// contract of `0` and the only thing that keeps abandoned entries from
1314/// accumulating forever. The grace seed for a missing sidecar stays
1315/// mtime-only because the true owner is unknown here.
1316fn reclaim_foreign_cache_entry(
1317    repo_root: &Path,
1318    path: &Path,
1319    max_age: Option<Duration>,
1320    now: SystemTime,
1321) -> SweepDisposition {
1322    // Like the owned pass, a probe error on the entry itself fails toward a
1323    // cheap self-rebuild and the orphan branch re-checks under the lock.
1324    if !path.exists() {
1325        return reclaim_orphan_cache_entry(repo_root, path);
1326    }
1327    let mut owner_missing = false;
1328    if let Some(owner) = read_last_used_owner(path) {
1329        match probe_owner_liveness(&owner) {
1330            OwnerLiveness::Live => return SweepDisposition::KeptOwnerLive,
1331            OwnerLiveness::Unverifiable(kind) => {
1332                return SweepDisposition::KeptOwnerUnverifiable(kind);
1333            }
1334            OwnerLiveness::Dead => owner_missing = true,
1335        }
1336    }
1337    let Some(max_age) = max_age else {
1338        if owner_missing {
1339            return remove_cache_entry_under_lock(
1340                repo_root,
1341                path,
1342                SweepDisposition::ReclaimedOwnerMissing,
1343            );
1344        }
1345        return SweepDisposition::KeptAgeGcDisabled;
1346    };
1347    let Some(mtime) = last_used_mtime(path) else {
1348        touch_last_used(path);
1349        return SweepDisposition::KeptGraceSeeded;
1350    };
1351    remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
1352}
1353
1354enum OwnerLiveness {
1355    Live,
1356    Dead,
1357    Unverifiable(std::io::ErrorKind),
1358}
1359
1360/// NotFound-only deadness probe for a recorded owner root (issue #2221).
1361///
1362/// `std::fs::metadata` follows symlinks, so a dangling-symlink owner root
1363/// resolves to NotFound (dead), matching the previous `Path::exists()`
1364/// behavior. Every other error (EACCES, EIO, ENOTDIR) classifies the owner as
1365/// unverifiable and keeps the entry: a transient probe failure must never let
1366/// this repo's sweep reclaim another live repo's warm cache or defeat its
1367/// `cacheMaxAgeDays: 0` policy. A path below an unmounted mountpoint still
1368/// reads as NotFound, so that case is NOT protected.
1369fn probe_owner_liveness(owner: &Path) -> OwnerLiveness {
1370    match std::fs::metadata(owner) {
1371        Ok(_) => OwnerLiveness::Live,
1372        Err(error) if error.kind() == std::io::ErrorKind::NotFound => OwnerLiveness::Dead,
1373        Err(error) => OwnerLiveness::Unverifiable(error.kind()),
1374    }
1375}
1376
1377/// Whether the owner-liveness gate applies to a dry-run classification (it
1378/// only ever applies to the cross-repo pass).
1379#[derive(Clone, Copy, PartialEq, Eq)]
1380enum OwnerGate {
1381    On,
1382    Off,
1383}
1384
1385/// Read-only disposition classifier for [`SweepMode::DryRun`]. Mirrors the
1386/// decision order of [`reclaim_reusable_cache_entry`] /
1387/// [`reclaim_foreign_cache_entry`] exactly, minus locks and mutation, so a
1388/// preview predicts what an apply run would do (modulo concurrent runs, the
1389/// same caveat `audit-cache remove --dry-run` accepts).
1390fn classify_entry_dry_run(
1391    path: &Path,
1392    max_age: Option<Duration>,
1393    now: SystemTime,
1394    owner_gate: OwnerGate,
1395) -> SweepDisposition {
1396    if !path.exists() {
1397        if !reusable_cache_entry_exists(path) {
1398            return SweepDisposition::KeptLockOnly;
1399        }
1400        return classify_would_remove(path, SweepDisposition::ReclaimedOrphan);
1401    }
1402    let mut owner_missing = false;
1403    if owner_gate == OwnerGate::On
1404        && let Some(owner) = read_last_used_owner(path)
1405    {
1406        match probe_owner_liveness(&owner) {
1407            OwnerLiveness::Live => return SweepDisposition::KeptOwnerLive,
1408            OwnerLiveness::Unverifiable(kind) => {
1409                return SweepDisposition::KeptOwnerUnverifiable(kind);
1410            }
1411            OwnerLiveness::Dead => owner_missing = true,
1412        }
1413    }
1414    let Some(max_age) = max_age else {
1415        if owner_missing {
1416            return classify_would_remove(path, SweepDisposition::ReclaimedOwnerMissing);
1417        }
1418        return SweepDisposition::KeptAgeGcDisabled;
1419    };
1420    let Some(mtime) = last_used_mtime(path) else {
1421        return SweepDisposition::KeptGraceSeeded;
1422    };
1423    let Ok(age) = now.duration_since(mtime) else {
1424        return SweepDisposition::KeptFresh;
1425    };
1426    if age < max_age {
1427        return SweepDisposition::KeptFresh;
1428    }
1429    classify_would_remove(path, SweepDisposition::ReclaimedAged)
1430}
1431
1432/// Apply the same up-front ownership classification to a dry-run
1433/// "would remove" verdict that [`remove_cache_entry_for_sweep`] applies to a
1434/// real removal.
1435fn classify_would_remove(path: &Path, removed: SweepDisposition) -> SweepDisposition {
1436    match cache_entry_ownership(path) {
1437        Ok(CacheEntryOwnership::Owned) => removed,
1438        Ok(CacheEntryOwnership::Unowned(_)) => SweepDisposition::KeptNotOwned,
1439        Err(_) => SweepDisposition::RemoveFailed,
1440    }
1441}
1442
1443fn last_used_mtime(path: &Path) -> Option<SystemTime> {
1444    std::fs::metadata(reusable_worktree_last_used_path(path))
1445        .ok()
1446        .and_then(|metadata| metadata.modified().ok())
1447}
1448
1449fn remove_entry_past_max_age(
1450    repo_root: &Path,
1451    path: &Path,
1452    max_age: Duration,
1453    now: SystemTime,
1454    mtime: SystemTime,
1455) -> SweepDisposition {
1456    let Ok(age) = now.duration_since(mtime) else {
1457        return SweepDisposition::KeptFresh;
1458    };
1459    if age < max_age {
1460        return SweepDisposition::KeptFresh;
1461    }
1462    remove_cache_entry_under_lock(repo_root, path, SweepDisposition::ReclaimedAged)
1463}
1464
1465/// Lock, remove, and map the outcome, reporting `removed` on success.
1466fn remove_cache_entry_under_lock(
1467    repo_root: &Path,
1468    path: &Path,
1469    removed: SweepDisposition,
1470) -> SweepDisposition {
1471    let Some(_lock) = ReusableWorktreeLock::try_acquire(path, GC_LOCK_CONTEXT) else {
1472        return SweepDisposition::SkippedLocked;
1473    };
1474    match remove_cache_entry_for_sweep(repo_root, path) {
1475        Ok(CacheRemovalOutcome::Removed) => removed,
1476        Ok(CacheRemovalOutcome::NothingLeft) => SweepDisposition::KeptLockOnly,
1477        Ok(CacheRemovalOutcome::NotOwned) => SweepDisposition::KeptNotOwned,
1478        Err(err) => {
1479            tracing::warn!(
1480                path = %path.display(),
1481                error = %err,
1482                "failed to remove stale reusable audit worktree entry; entry may leak",
1483            );
1484            SweepDisposition::RemoveFailed
1485        }
1486    }
1487}
1488
1489enum CacheRemovalOutcome {
1490    Removed,
1491    /// Nothing but the (never-deleted) `.lock` sidecar was left to remove.
1492    NothingLeft,
1493    /// The Unix uid ownership check rejected the entry; nothing was touched.
1494    NotOwned,
1495}
1496
1497/// Sweep-side removal wrapper: classifies unowned entries up front (a shared
1498/// runner's other-user entries are a kept outcome, not a failure) and reserves
1499/// `Err` for genuine IO errors after the ownership check passed.
1500fn remove_cache_entry_for_sweep(
1501    repo_root: &Path,
1502    path: &Path,
1503) -> std::io::Result<CacheRemovalOutcome> {
1504    if matches!(
1505        cache_entry_ownership(path)?,
1506        CacheEntryOwnership::Unowned(_)
1507    ) {
1508        return Ok(CacheRemovalOutcome::NotOwned);
1509    }
1510    remove_reusable_cache_entry_locked(repo_root, path).map(|removed| {
1511        if removed {
1512            CacheRemovalOutcome::Removed
1513        } else {
1514            CacheRemovalOutcome::NothingLeft
1515        }
1516    })
1517}
1518
1519/// Recursive byte count of the regular files under `root`.
1520///
1521/// A deliberate plain `read_dir` walk: an `ignore`-crate walk would honor the
1522/// `.gitignore` checked out INSIDE the cache snapshot and skip `node_modules`,
1523/// the bulk of the mass being measured. Symlinks are never followed
1524/// (`DirEntry::metadata` does not traverse them), so pnpm stores and
1525/// cross-volume links cannot inflate or loop the walk; per-entry errors are
1526/// tolerated. Returns `None` when `root` itself is absent or unreadable.
1527fn directory_size_bytes(root: &Path) -> Option<u64> {
1528    let root_entries = std::fs::read_dir(root).ok()?;
1529    let mut total: u64 = 0;
1530    let mut stack = vec![root_entries];
1531    while let Some(entries) = stack.pop() {
1532        for entry in entries.flatten() {
1533            let Ok(metadata) = entry.metadata() else {
1534                continue;
1535            };
1536            if metadata.is_dir() {
1537                if let Ok(child) = std::fs::read_dir(entry.path()) {
1538                    stack.push(child);
1539                }
1540            } else if metadata_is_regular_file(&metadata) {
1541                total = total.saturating_add(metadata.len());
1542            }
1543        }
1544    }
1545    Some(total)
1546}
1547
1548/// Measure candidate entry sizes in parallel on the already-configured rayon
1549/// pool. Returns an empty map under [`SweepSizes::Skip`].
1550fn measure_entry_sizes<'a>(
1551    sizes: SweepSizes,
1552    paths: impl Iterator<Item = &'a PathBuf>,
1553) -> FxHashMap<PathBuf, Option<u64>> {
1554    use rayon::iter::{IntoParallelRefIterator as _, ParallelIterator as _};
1555
1556    if sizes == SweepSizes::Skip {
1557        return FxHashMap::default();
1558    }
1559    let paths: Vec<&PathBuf> = paths.collect();
1560    paths
1561        .par_iter()
1562        .map(|path| ((*path).clone(), directory_size_bytes(path)))
1563        .collect()
1564}
1565
1566pub fn canonical_root_hash(root: &Path) -> u64 {
1567    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1568    xxh3_64(&path_identity_bytes(&canonical_root))
1569}
1570
1571#[cfg(unix)]
1572fn path_identity_bytes(path: &Path) -> Vec<u8> {
1573    use std::os::unix::ffi::OsStrExt as _;
1574
1575    path.as_os_str().as_bytes().to_vec()
1576}
1577
1578#[cfg(windows)]
1579fn path_identity_bytes(path: &Path) -> Vec<u8> {
1580    use std::os::windows::ffi::OsStrExt as _;
1581
1582    path.as_os_str()
1583        .encode_wide()
1584        .flat_map(u16::to_le_bytes)
1585        .collect()
1586}
1587
1588#[cfg(not(any(unix, windows)))]
1589fn path_identity_bytes(path: &Path) -> Vec<u8> {
1590    path.to_string_lossy().as_bytes().to_vec()
1591}
1592
1593pub fn reusable_audit_worktree_path(requested_root: &Path) -> PathBuf {
1594    let root_hash = canonical_root_hash(requested_root);
1595    let repo_hash = git_toplevel(requested_root)
1596        .as_deref()
1597        .map_or(root_hash, canonical_root_hash);
1598    std::env::temp_dir().join(format!(
1599        "fallow-audit-base-cache-{repo_hash:016x}-root-{root_hash:016x}"
1600    ))
1601}
1602
1603fn root_owned_cache_repo_prefix(requested_root: &Path) -> Option<String> {
1604    let git_root = git_toplevel(requested_root)?;
1605    let repo_hash = canonical_root_hash(&git_root);
1606    Some(format!("fallow-audit-base-cache-{repo_hash:016x}-root-"))
1607}
1608
1609fn legacy_reusable_cache_repo_prefix(requested_root: &Path) -> Option<String> {
1610    let git_root = git_toplevel(requested_root)?;
1611    let repo_hash = canonical_root_hash(&git_root);
1612    Some(format!("fallow-audit-base-cache-{repo_hash:016x}-"))
1613}
1614
1615#[cfg(test)]
1616pub fn legacy_reusable_audit_worktree_path(
1617    requested_root: &Path,
1618    base_sha: &str,
1619) -> Option<PathBuf> {
1620    let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
1621    Some(std::env::temp_dir().join(format!(
1622        "{}{sha_prefix}",
1623        legacy_reusable_cache_repo_prefix(requested_root)?
1624    )))
1625}
1626
1627/// Readiness for a reusable cache HIT: the directory exists and its `.sha`
1628/// sidecar records exactly `base_sha`.
1629///
1630/// Fidelity is equivalent to the old in-worktree `git rev-parse HEAD` probe:
1631/// that probe read the host admin dir's HEAD, never the snapshot's on-disk
1632/// content, so neither approach detects content damage. The `.sha` is only
1633/// ever written after a successful materialization + deregistration, so a
1634/// torn snapshot never presents a matching sidecar. On a hit the `.git` stub
1635/// is repaired idempotently so gitignore parity holds even if the stub was
1636/// removed out-of-band.
1637fn reusable_audit_worktree_is_ready(path: &Path, base_sha: &str) -> bool {
1638    if !reusable_cache_directory_is_trusted(path) {
1639        return false;
1640    }
1641    let recorded = read_reusable_sha(path);
1642    if recorded.as_deref() != Some(base_sha) {
1643        return false;
1644    }
1645    repair_unregistered_git_stub(path)
1646}
1647
1648fn read_reusable_sha(path: &Path) -> Option<String> {
1649    const MAX_SHA_SIDECAR_BYTES: u64 = 129;
1650
1651    let sidecar = reusable_worktree_sha_path(path);
1652    let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
1653    if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_SHA_SIDECAR_BYTES {
1654        return None;
1655    }
1656    let mut contents = String::new();
1657    std::fs::File::open(sidecar)
1658        .ok()?
1659        .take(MAX_SHA_SIDECAR_BYTES)
1660        .read_to_string(&mut contents)
1661        .ok()?;
1662    Some(contents.trim().to_owned())
1663}
1664
1665#[cfg(unix)]
1666fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
1667    use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1668
1669    let Ok(metadata) = std::fs::symlink_metadata(path) else {
1670        return false;
1671    };
1672    metadata.file_type().is_dir()
1673        && metadata.uid() == rustix::process::geteuid().as_raw()
1674        && metadata.permissions().mode().trailing_zeros() >= 6
1675}
1676
1677#[cfg(not(unix))]
1678fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
1679    std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
1680}
1681
1682/// Recover a current-path cache that is still a registered Git worktree.
1683///
1684/// This can occur during mixed-version use or after interruption between
1685/// registration and deregistration. Keep the cache warm only when HEAD matches
1686/// the requested full SHA and raw materialization completed.
1687fn try_migrate_registered_current_cache(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
1688    if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
1689        return false;
1690    }
1691    let head_matches = git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha);
1692    if !head_matches || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
1693    {
1694        return false;
1695    }
1696    if unregister_worktree_checked(repo_root, path).is_err() {
1697        return false;
1698    }
1699    write_reusable_sha(path, base_sha).is_ok()
1700}
1701
1702/// Remove every reusable base-snapshot cache owned by `requested_root`.
1703///
1704/// The root-owned entry and every SHA-suffixed entry from the old
1705/// git-top-level identity are locked independently. Contended entries are
1706/// reported as skipped. Lock files are permanent lock identities and are never
1707/// removed.
1708#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1709pub struct AuditCacheRemovalReport {
1710    pub found: usize,
1711    pub removed: usize,
1712    pub skipped: usize,
1713    pub dry_run: bool,
1714}
1715
1716pub fn remove_reusable_audit_caches(
1717    requested_root: &Path,
1718    dry_run: bool,
1719) -> std::io::Result<AuditCacheRemovalReport> {
1720    let mut paths = vec![reusable_audit_worktree_path(requested_root)];
1721    paths.extend(scan_legacy_reusable_cache_paths(
1722        requested_root,
1723        &std::env::temp_dir(),
1724    ));
1725    if git_toplevel(requested_root).is_some_and(|root| paths_equal(&root, requested_root)) {
1726        paths.extend(scan_root_owned_cache_paths(requested_root));
1727    }
1728    paths.sort();
1729    paths.dedup();
1730
1731    let mut report = AuditCacheRemovalReport {
1732        found: 0,
1733        removed: 0,
1734        skipped: 0,
1735        dry_run,
1736    };
1737    for path in paths {
1738        if !reusable_cache_entry_exists(&path) {
1739            continue;
1740        }
1741        report.found += 1;
1742        if dry_run {
1743            // A preview must not touch the filesystem. Acquiring the lock would
1744            // create the `.lock` sidecar (create_new) for entries that lack one,
1745            // violating the documented --dry-run contract. Contention is a race
1746            // only the real removal reports.
1747            continue;
1748        }
1749        let Some(_lock) =
1750            ReusableWorktreeLock::try_acquire(&path, "cache removal reports the entry as skipped")
1751        else {
1752            report.skipped += 1;
1753            continue;
1754        };
1755        if remove_reusable_cache_entry_locked(requested_root, &path)? {
1756            report.removed += 1;
1757        }
1758    }
1759    Ok(report)
1760}
1761
1762fn reusable_cache_entry_exists(path: &Path) -> bool {
1763    path_entry_exists(path)
1764        || path_entry_exists(&reusable_worktree_sha_path(path))
1765        || path_entry_exists(&reusable_worktree_last_used_path(path))
1766}
1767
1768/// Like [`reusable_cache_entry_exists`] but also counts a surviving `.lock`
1769/// sidecar, which is deliberately never deleted and marks a real (if fully
1770/// reclaimed) cache identity worth reporting.
1771fn cache_entry_has_presence(path: &Path) -> bool {
1772    reusable_cache_entry_exists(path) || path_entry_exists(&reusable_worktree_lock_path(path))
1773}
1774
1775fn path_entry_exists(path: &Path) -> bool {
1776    std::fs::symlink_metadata(path).is_ok()
1777}
1778
1779/// Remove one reusable cache while its caller holds the entry's exclusive
1780/// lock. Absence is success. The lock sidecar is deliberately preserved.
1781fn remove_reusable_cache_entry_locked(repo_root: &Path, path: &Path) -> std::io::Result<bool> {
1782    let existed = reusable_cache_entry_exists(path);
1783    ensure_cache_entry_is_owned(path)?;
1784    if trusted_worktree_admin_dir(repo_root, path).is_some() {
1785        unregister_worktree_checked(repo_root, path)?;
1786    }
1787    remove_dir_if_exists(path)?;
1788    remove_file_if_exists(&reusable_worktree_sha_path(path))?;
1789    remove_file_if_exists(&reusable_worktree_last_used_path(path))?;
1790    Ok(existed)
1791}
1792
1793enum CacheEntryOwnership {
1794    Owned,
1795    #[cfg_attr(
1796        not(unix),
1797        expect(dead_code, reason = "only the Unix uid probe constructs this variant")
1798    )]
1799    Unowned(PathBuf),
1800}
1801
1802#[cfg(unix)]
1803fn cache_entry_ownership(path: &Path) -> std::io::Result<CacheEntryOwnership> {
1804    use std::os::unix::fs::MetadataExt as _;
1805
1806    let effective_uid = rustix::process::geteuid().as_raw();
1807    for entry in [
1808        path.to_path_buf(),
1809        reusable_worktree_sha_path(path),
1810        reusable_worktree_last_used_path(path),
1811    ] {
1812        let metadata = match std::fs::symlink_metadata(&entry) {
1813            Ok(metadata) => metadata,
1814            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1815            Err(error) => return Err(error),
1816        };
1817        if metadata.uid() != effective_uid {
1818            return Ok(CacheEntryOwnership::Unowned(entry));
1819        }
1820    }
1821    Ok(CacheEntryOwnership::Owned)
1822}
1823
1824#[cfg(not(unix))]
1825#[expect(
1826    clippy::unnecessary_wraps,
1827    reason = "shared cross-platform signature; the Unix ownership check is fallible, non-Unix has no POSIX owner to verify"
1828)]
1829fn cache_entry_ownership(_path: &Path) -> std::io::Result<CacheEntryOwnership> {
1830    Ok(CacheEntryOwnership::Owned)
1831}
1832
1833fn ensure_cache_entry_is_owned(path: &Path) -> std::io::Result<()> {
1834    match cache_entry_ownership(path)? {
1835        CacheEntryOwnership::Owned => Ok(()),
1836        CacheEntryOwnership::Unowned(entry) => Err(std::io::Error::new(
1837            std::io::ErrorKind::PermissionDenied,
1838            format!(
1839                "refusing to remove unowned audit cache entry `{}`",
1840                entry.display()
1841            ),
1842        )),
1843    }
1844}
1845
1846fn remove_dir_if_exists(path: &Path) -> std::io::Result<()> {
1847    match std::fs::remove_dir_all(path) {
1848        Ok(()) => Ok(()),
1849        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1850        Err(err) => Err(err),
1851    }
1852}
1853
1854fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
1855    match std::fs::remove_file(path) {
1856        Ok(()) => Ok(()),
1857        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1858        Err(err) => Err(err),
1859    }
1860}
1861
1862/// Deregister a freshly-added audit worktree from git while KEEPING its
1863/// directory on disk (issue #1815).
1864///
1865/// Targets the single admin dir this worktree owns via its `.git` gitfile
1866/// pointer, rather than a global `git worktree prune`, so a user's unrelated
1867/// prunable worktrees are never collaterally deregistered and git's
1868/// name-collision admin suffixing (`<name>1`) is handled for free. The `.git`
1869/// gitfile is REPLACED with an invalid stub (see [`UNREGISTERED_GITDIR_STUB`]),
1870/// never deleted.
1871pub fn unregister_worktree(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1872    unregister_worktree_checked(repo_root, path)
1873}
1874
1875fn unregister_worktree_checked(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1876    if !path.exists() {
1877        return Ok(());
1878    }
1879    let gitfile = path.join(".git");
1880    let metadata = std::fs::symlink_metadata(&gitfile)?;
1881    if !metadata_is_regular_file(&metadata) {
1882        return Err(std::io::Error::new(
1883            std::io::ErrorKind::InvalidData,
1884            "refusing to deregister through a non-file audit worktree .git entry",
1885        ));
1886    }
1887    let contents = std::fs::read_to_string(&gitfile)?;
1888    if contents == UNREGISTERED_GITDIR_STUB {
1889        return Ok(());
1890    }
1891    let Some(admin_dir) = trusted_worktree_admin_dir(repo_root, path) else {
1892        return Err(std::io::Error::new(
1893            std::io::ErrorKind::InvalidData,
1894            "refusing to deregister an unverified audit worktree admin entry",
1895        ));
1896    };
1897    remove_dir_if_exists(&admin_dir)?;
1898    write_git_stub_safely(&gitfile)
1899}
1900
1901/// Ensure a cache hit's `.git` stub stays valid. Recreates the stub if the
1902/// gitfile is gone (so gitignore parity holds), and deregisters in place if a
1903/// live pointer to a fallow admin dir is found (a mixed-version re-registration
1904/// or a torn transient window).
1905fn repair_unregistered_git_stub(path: &Path) -> bool {
1906    let gitfile = path.join(".git");
1907    let Ok(metadata) = std::fs::symlink_metadata(&gitfile) else {
1908        return write_git_stub_safely(&gitfile).is_ok();
1909    };
1910    if !metadata_is_regular_file(&metadata) {
1911        return false;
1912    }
1913    std::fs::read_to_string(&gitfile).is_ok_and(|contents| contents == UNREGISTERED_GITDIR_STUB)
1914}
1915
1916fn write_git_stub_safely(gitfile: &Path) -> std::io::Result<()> {
1917    let mut options = std::fs::OpenOptions::new();
1918    options.write(true).truncate(true);
1919    match std::fs::symlink_metadata(gitfile) {
1920        Ok(metadata) if metadata_is_regular_file(&metadata) => {}
1921        Ok(_) => {
1922            return Err(std::io::Error::new(
1923                std::io::ErrorKind::InvalidData,
1924                "refusing to replace non-file audit worktree .git entry",
1925            ));
1926        }
1927        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1928            options.create_new(true);
1929        }
1930        Err(error) => return Err(error),
1931    }
1932    let mut file = options.open(gitfile)?;
1933    file.write_all(UNREGISTERED_GITDIR_STUB.as_bytes())?;
1934    file.sync_all()
1935}
1936
1937fn trusted_worktree_admin_dir(repo_root: &Path, path: &Path) -> Option<PathBuf> {
1938    let gitfile = path.join(".git");
1939    let metadata = std::fs::symlink_metadata(&gitfile).ok()?;
1940    if !metadata_is_regular_file(&metadata) {
1941        return None;
1942    }
1943    let contents = std::fs::read_to_string(&gitfile).ok()?;
1944    let admin_dir = parse_worktree_gitdir(&contents)?;
1945    if !is_fallow_admin_dir(&admin_dir) {
1946        return None;
1947    }
1948    let common_dir = fallow_engine::changed_files::resolve_git_common_dir(repo_root).ok()?;
1949    let worktrees_dir = dunce::canonicalize(common_dir.join("worktrees")).ok()?;
1950    let admin_parent = dunce::canonicalize(admin_dir.parent()?).ok()?;
1951    if admin_parent != worktrees_dir {
1952        return None;
1953    }
1954    let backlink = std::fs::read_to_string(admin_dir.join("gitdir")).ok()?;
1955    let expected_gitfile = dunce::canonicalize(&gitfile).ok()?;
1956    let actual_gitfile = dunce::canonicalize(Path::new(backlink.trim())).ok()?;
1957    (actual_gitfile == expected_gitfile).then_some(admin_dir)
1958}
1959
1960/// Parse the `gitdir: <path>` pointer from a linked-worktree `.git` gitfile.
1961fn parse_worktree_gitdir(contents: &str) -> Option<PathBuf> {
1962    contents
1963        .lines()
1964        .find_map(|line| line.trim().strip_prefix("gitdir:"))
1965        .map(|rest| PathBuf::from(rest.trim()))
1966}
1967
1968/// True when an admin dir basename is one fallow itself created, used as a
1969/// safety belt before removing it.
1970fn is_fallow_admin_dir(admin_dir: &Path) -> bool {
1971    admin_dir
1972        .file_name()
1973        .and_then(|name| name.to_str())
1974        .is_some_and(|name| name.starts_with("fallow-audit-base-"))
1975}
1976
1977pub fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
1978    let mut command = Command::new("git");
1979    command.args(["rev-parse", rev]).current_dir(root);
1980    clear_ambient_git_env(&mut command);
1981    let output = command.output().ok()?;
1982    if !output.status.success() {
1983        return None;
1984    }
1985    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1986}
1987
1988pub fn git_toplevel(root: &Path) -> Option<PathBuf> {
1989    let mut command = Command::new("git");
1990    command
1991        .args(["rev-parse", "--show-toplevel"])
1992        .current_dir(root);
1993    clear_ambient_git_env(&mut command);
1994    let output = command.output().ok()?;
1995    if !output.status.success() {
1996        return None;
1997    }
1998    let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1999    Some(dunce::canonicalize(&path).unwrap_or(path))
2000}
2001
2002fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
2003    let Some(worktrees) = list_audit_worktrees(repo_root) else {
2004        return false;
2005    };
2006    worktrees.iter().any(|worktree| paths_equal(worktree, path))
2007}
2008
2009pub fn paths_equal(left: &Path, right: &Path) -> bool {
2010    if left == right {
2011        return true;
2012    }
2013    match (dunce::canonicalize(left), dunce::canonicalize(right)) {
2014        (Ok(left), Ok(right)) => left == right,
2015        _ => false,
2016    }
2017}
2018
2019pub fn remove_audit_worktree(repo_root: &Path, path: &Path) {
2020    let mut command = Command::new("git");
2021    command
2022        .args([
2023            "worktree",
2024            "remove",
2025            "--force",
2026            path.to_string_lossy().as_ref(),
2027        ])
2028        .current_dir(repo_root);
2029    clear_ambient_git_env(&mut command);
2030    match crate::signal::scoped_child::output(&mut command) {
2031        Ok(output) => {
2032            if !output.status.success() && path.exists() {
2033                let stderr = String::from_utf8_lossy(&output.stderr);
2034                tracing::warn!(
2035                    path = %path.display(),
2036                    stderr = %stderr.trim(),
2037                    "git worktree remove failed; the directory remains and may leak",
2038                );
2039            }
2040        }
2041        Err(err) => {
2042            tracing::warn!(
2043                path = %path.display(),
2044                error = %err,
2045                "git worktree remove subprocess failed to spawn",
2046            );
2047        }
2048    }
2049}
2050
2051pub fn sweep_orphan_audit_worktrees(repo_root: &Path) {
2052    sweep_orphan_audit_worktrees_in(repo_root, &std::env::temp_dir());
2053}
2054
2055/// `temp_root` is the directory scanned for unregistered dead-pid worktree
2056/// directories. Production always passes `std::env::temp_dir()`; tests pass a
2057/// private root because a fabricated dead-pid fixture in the SHARED temp dir
2058/// is legitimate prey for any concurrent sweep (a parallel test or a spawned
2059/// fallow binary), which races the fixture's own assertions.
2060pub fn sweep_orphan_audit_worktrees_in(repo_root: &Path, temp_root: &Path) {
2061    // Legacy pass: deregister dead-pid non-reusable worktrees left REGISTERED
2062    // by pre-#1815 fallow (or by a crash in the transient registration
2063    // window). The `--expire=now` prune, retained only here, also sweeps any
2064    // now-dangling admin entry.
2065    if deregister_legacy_orphan_worktrees(repo_root) {
2066        let mut command = Command::new("git");
2067        command
2068            .args(["worktree", "prune", "--expire=now"])
2069            .current_dir(repo_root);
2070        clear_ambient_git_env(&mut command);
2071        let _ = command.output();
2072    }
2073
2074    // Primary pass: remove unregistered dead-pid worktree DIRECTORIES via a
2075    // temp-dir prefix scan. A dead PID means the owning process is gone
2076    // regardless of repo, so this scan is global (not repo-scoped).
2077    for path in scan_non_reusable_orphan_paths(temp_root) {
2078        let _ = std::fs::remove_dir_all(&path);
2079    }
2080}
2081
2082/// Deregister dead-pid non-reusable worktrees left REGISTERED by pre-#1815
2083/// fallow or by a crash mid-registration. Returns `true` when any were removed.
2084fn deregister_legacy_orphan_worktrees(repo_root: &Path) -> bool {
2085    let Some(worktrees) = list_audit_worktrees(repo_root) else {
2086        return false;
2087    };
2088    let mut removed_any = false;
2089    for path in worktrees {
2090        if !is_fallow_audit_worktree_path(&path)
2091            || is_reusable_audit_worktree_path(&path)
2092            || audit_worktree_process_is_alive(&path)
2093        {
2094            continue;
2095        }
2096        remove_audit_worktree(repo_root, &path);
2097        let _ = std::fs::remove_dir_all(&path);
2098        removed_any = true;
2099    }
2100    removed_any
2101}
2102
2103/// Enumerate unregistered non-reusable worktree DIRECTORY paths owned by a
2104/// dead PID. Reusable caches yield no parseable PID and are skipped.
2105fn scan_non_reusable_orphan_paths(temp: &Path) -> Vec<PathBuf> {
2106    let Ok(entries) = std::fs::read_dir(temp) else {
2107        return Vec::new();
2108    };
2109    let mut paths = Vec::new();
2110    for entry in entries.flatten() {
2111        let name = entry.file_name();
2112        let Some(name) = name.to_str() else {
2113            continue;
2114        };
2115        let Some(pid) = audit_worktree_pid(name) else {
2116            continue;
2117        };
2118        if process_is_alive(pid) || !entry.path().is_dir() {
2119            continue;
2120        }
2121        paths.push(temp.join(name));
2122    }
2123    paths
2124}
2125
2126pub fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
2127    let mut command = Command::new("git");
2128    command
2129        .args(["worktree", "list", "--porcelain"])
2130        .current_dir(repo_root);
2131    clear_ambient_git_env(&mut command);
2132    let output = command.output().ok()?;
2133    if !output.status.success() {
2134        return None;
2135    }
2136    Some(parse_worktree_list(&String::from_utf8_lossy(
2137        &output.stdout,
2138    )))
2139}
2140
2141pub fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
2142    output
2143        .lines()
2144        .filter_map(|line| line.strip_prefix("worktree "))
2145        .map(PathBuf::from)
2146        .filter(|path| is_fallow_audit_worktree_path(path))
2147        .collect()
2148}
2149
2150pub fn is_fallow_audit_worktree_path(path: &Path) -> bool {
2151    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
2152        return false;
2153    };
2154    name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
2155}
2156
2157pub fn is_reusable_audit_worktree_path(path: &Path) -> bool {
2158    path.file_name()
2159        .and_then(|name| name.to_str())
2160        .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
2161}
2162
2163fn path_is_inside_temp_dir(path: &Path) -> bool {
2164    let temp = std::env::temp_dir();
2165    let simple_path = dunce::simplified(path);
2166    let simple_temp = dunce::simplified(&temp);
2167    if simple_path.starts_with(simple_temp) {
2168        return true;
2169    }
2170    let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
2171        return false;
2172    };
2173    let simple_canonical_temp = dunce::simplified(&canonical_temp);
2174    simple_path.starts_with(simple_canonical_temp)
2175        || std::fs::canonicalize(path).is_ok_and(|canonical_path| {
2176            dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
2177        })
2178}
2179
2180fn audit_worktree_process_is_alive(path: &Path) -> bool {
2181    let Some(pid) = path
2182        .file_name()
2183        .and_then(|name| name.to_str())
2184        .and_then(audit_worktree_pid)
2185    else {
2186        return false;
2187    };
2188    process_is_alive(pid)
2189}
2190
2191pub fn audit_worktree_pid(name: &str) -> Option<u32> {
2192    name.strip_prefix("fallow-audit-base-")?
2193        .split('-')
2194        .next()?
2195        .parse()
2196        .ok()
2197}
2198
2199#[cfg(unix)]
2200pub fn process_is_alive(pid: u32) -> bool {
2201    Command::new("kill")
2202        .args(["-0", &pid.to_string()])
2203        .output()
2204        .is_ok_and(|output| output.status.success())
2205}
2206
2207#[cfg(windows)]
2208pub fn process_is_alive(pid: u32) -> bool {
2209    windows_process::is_alive(pid)
2210}
2211
2212#[cfg(not(any(unix, windows)))]
2213pub fn process_is_alive(_pid: u32) -> bool {
2214    true
2215}
2216
2217#[cfg(windows)]
2218#[allow(
2219    unsafe_code,
2220    reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
2221)]
2222mod windows_process {
2223    use windows_sys::Win32::Foundation::{
2224        CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
2225        WAIT_OBJECT_0,
2226    };
2227    use windows_sys::Win32::System::Threading::{
2228        OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
2229    };
2230
2231    /// RAII wrapper that calls `CloseHandle` on drop, mirroring `std::mem::drop`
2232    /// semantics for kernel handles. Used so every exit path through
2233    /// `is_alive` releases the handle without manual cleanup.
2234    struct ProcessHandle(HANDLE);
2235
2236    impl Drop for ProcessHandle {
2237        fn drop(&mut self) {
2238            // SAFETY: `self.0` is a non-null handle obtained from a successful
2239            // `OpenProcess` call. We have unique ownership (the value is only
2240            // ever created inside `is_alive`), so this is the sole consumer.
2241            unsafe {
2242                CloseHandle(self.0);
2243            }
2244        }
2245    }
2246
2247    /// Cross-platform PID liveness check for Windows.
2248    ///
2249    /// Mirrors `kill -0 $pid` semantics: returns `true` when the process is
2250    /// running OR when we cannot prove it dead (e.g., `ERROR_ACCESS_DENIED` on
2251    /// processes owned by another session). Returns `false` only when the PID
2252    /// definitively does not exist (`ERROR_INVALID_PARAMETER`) or the wait
2253    /// reports the process has exited.
2254    pub fn is_alive(pid: u32) -> bool {
2255        // SAFETY: `OpenProcess` accepts any `u32` PID; it either returns a
2256        // non-null handle we own, or null on failure with `GetLastError`
2257        // describing why. No memory is borrowed across the FFI boundary.
2258        let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
2259        if raw.is_null() {
2260            // SAFETY: `GetLastError` reads thread-local storage set by the
2261            // failing `OpenProcess` call. It has no preconditions.
2262            let err = unsafe { GetLastError() };
2263            #[expect(
2264                clippy::match_same_arms,
2265                reason = "named arm documents the cross-session case"
2266            )]
2267            return match err {
2268                ERROR_INVALID_PARAMETER => false,
2269                ERROR_ACCESS_DENIED => true,
2270                _ => true,
2271            };
2272        }
2273        let handle = ProcessHandle(raw);
2274        // SAFETY: `handle.0` is non-null (checked above) and owned by the
2275        // `ProcessHandle` RAII wrapper.
2276        let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
2277        wait_result != WAIT_OBJECT_0
2278    }
2279}
2280
2281impl Drop for BaseWorktree {
2282    fn drop(&mut self) {
2283        if self.persistent {
2284            return;
2285        }
2286        // The non-reusable worktree was deregistered right after creation, so
2287        // cleanup is a plain directory removal: no `git` subprocess runs, and
2288        // a SIGKILL before this Drop can never leave an admin entry behind.
2289        let _ = std::fs::remove_dir_all(&self.path);
2290    }
2291}
2292
2293#[cfg(test)]
2294mod tests {
2295    use super::*;
2296
2297    /// Many threads minting a non-reusable worktree path at the same instant
2298    /// must each get a distinct path. Before the monotonic counter, concurrent
2299    /// callers read the same non-monotonic `nanos` and collided, so two parallel
2300    /// `audit` runs in one process raced on `git worktree add` and one aborted
2301    /// with a generic error (a flaky exit 2 under parallel unit tests).
2302    #[test]
2303    fn non_reusable_worktree_paths_are_unique_under_concurrency() {
2304        const N: usize = 64;
2305        let barrier = std::sync::Barrier::new(N);
2306        let paths = std::sync::Mutex::new(Vec::with_capacity(N));
2307        std::thread::scope(|s| {
2308            for _ in 0..N {
2309                let barrier = &barrier;
2310                let paths = &paths;
2311                s.spawn(move || {
2312                    barrier.wait();
2313                    let path = non_reusable_worktree_path().expect("path should build");
2314                    paths.lock().unwrap().push(path);
2315                });
2316            }
2317        });
2318        let mut paths = paths.into_inner().unwrap();
2319        assert_eq!(paths.len(), N);
2320        paths.sort();
2321        paths.dedup();
2322        assert_eq!(paths.len(), N, "non-reusable worktree paths collided");
2323    }
2324
2325    /// The pid stays the first segment so orphan-sweep parsing keeps working.
2326    #[test]
2327    fn non_reusable_worktree_path_pid_is_parseable() {
2328        let path = non_reusable_worktree_path().expect("path should build");
2329        let name = path.file_name().unwrap().to_str().unwrap();
2330        assert!(is_fallow_audit_worktree_path(&path));
2331        assert!(!is_reusable_audit_worktree_path(&path));
2332        assert_eq!(audit_worktree_pid(name), Some(std::process::id()));
2333    }
2334
2335    #[test]
2336    fn directory_size_bytes_sums_regular_files_recursively() {
2337        let temp = tempfile::TempDir::new().expect("temp dir should be created");
2338        let root = temp.path().join("cache");
2339        std::fs::create_dir_all(root.join("node_modules/dep")).expect("tree should be created");
2340        std::fs::write(root.join("a.txt"), vec![0u8; 10]).expect("file should be written");
2341        std::fs::write(root.join("node_modules/dep/b.js"), vec![0u8; 32])
2342            .expect("nested file should be written");
2343        // A gitignore inside the snapshot must NOT hide the mass being
2344        // measured (the walk is deliberately ignore-crate-free).
2345        std::fs::write(root.join(".gitignore"), "node_modules\n")
2346            .expect("gitignore should be written");
2347
2348        let size = directory_size_bytes(&root).expect("size walk should succeed");
2349        assert_eq!(size, 10 + 32 + "node_modules\n".len() as u64);
2350        assert_eq!(
2351            directory_size_bytes(&temp.path().join("missing")),
2352            None,
2353            "an absent directory reports no size",
2354        );
2355    }
2356
2357    #[cfg(unix)]
2358    #[test]
2359    fn directory_size_bytes_never_follows_symlinks() {
2360        let temp = tempfile::TempDir::new().expect("temp dir should be created");
2361        let root = temp.path().join("cache");
2362        let outside = temp.path().join("outside");
2363        std::fs::create_dir_all(&root).expect("root should be created");
2364        std::fs::create_dir_all(&outside).expect("outside dir should be created");
2365        std::fs::write(outside.join("big.bin"), vec![0u8; 4096])
2366            .expect("outside file should be written");
2367        std::os::unix::fs::symlink(&outside, root.join("link-dir"))
2368            .expect("dir symlink should be created");
2369        std::os::unix::fs::symlink(outside.join("big.bin"), root.join("link-file"))
2370            .expect("file symlink should be created");
2371
2372        assert_eq!(
2373            directory_size_bytes(&root),
2374            Some(0),
2375            "symlinked directories and files must not be traversed or counted",
2376        );
2377    }
2378
2379    #[cfg(unix)]
2380    #[test]
2381    fn cache_sidecar_open_does_not_follow_symlinks() {
2382        let temp = tempfile::TempDir::new().expect("temp dir should be created");
2383        let victim = temp.path().join("victim");
2384        let sidecar = temp.path().join("cache.lock");
2385        std::fs::write(&victim, "unchanged\n").expect("victim should be written");
2386        std::os::unix::fs::symlink(&victim, &sidecar).expect("sidecar symlink should be created");
2387
2388        assert!(open_or_create_owned_sidecar(&sidecar).is_err());
2389        assert_eq!(
2390            std::fs::read_to_string(victim).expect("victim should remain readable"),
2391            "unchanged\n",
2392        );
2393    }
2394}