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