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;
8use rustc_hash::FxHashSet;
9use xxhash_rust::xxh3::xxh3_64;
10
11use crate::report::plural;
12
13pub struct BaseWorktree {
14    path: PathBuf,
15    persistent: bool,
16    _reusable_lock: Option<ReusableWorktreeLock>,
17}
18
19impl BaseWorktree {
20    pub fn create(repo_root: &Path, base_ref: &str, base_sha: Option<&str>) -> Option<Self> {
21        sweep_orphan_audit_worktrees(repo_root);
22        if let Some(base_sha) = base_sha
23            && let Some(worktree) = Self::reuse_or_create(repo_root, base_sha)
24        {
25            return Some(worktree);
26        }
27        let path = non_reusable_worktree_path()?;
28        let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
29        if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
30            repo_root,
31            guard.path(),
32            base_ref,
33        ) {
34            tracing::debug!(
35                base_ref,
36                error = %error,
37                "could not materialize non-reusable audit base worktree",
38            );
39            return None;
40        }
41        // Deregister immediately so a crash before Drop never leaves a git
42        // worktree admin entry behind (issue #1815). No early return runs
43        // between here and the struct binding, so defusing the guard next is
44        // safe: the entry is already unregistered.
45        if let Err(error) = unregister_worktree(repo_root, guard.path()) {
46            tracing::debug!(
47                path = %guard.path().display(),
48                error = %error,
49                "could not deregister non-reusable audit base worktree",
50            );
51            return None;
52        }
53        guard.defuse();
54        drop(guard);
55        let worktree = Self {
56            path,
57            persistent: false,
58            _reusable_lock: None,
59        };
60        materialize_base_dependency_context(repo_root, worktree.path());
61        Some(worktree)
62    }
63
64    pub fn reuse_or_create(repo_root: &Path, base_sha: &str) -> Option<Self> {
65        let path = reusable_audit_worktree_path(repo_root);
66        let reusable_lock = ReusableWorktreeLock::try_acquire(&path)?;
67
68        if reusable_audit_worktree_is_ready(&path, base_sha)
69            || try_migrate_registered_current_cache(repo_root, &path, base_sha)
70        {
71            let worktree = Self {
72                path,
73                persistent: true,
74                _reusable_lock: Some(reusable_lock),
75            };
76            materialize_base_dependency_context(repo_root, worktree.path());
77            touch_last_used(worktree.path());
78            return Some(worktree);
79        }
80
81        if let Err(error) = remove_file_if_exists(&reusable_worktree_sha_path(&path)) {
82            tracing::debug!(
83                path = %path.display(),
84                error = %error,
85                "could not clear reusable audit worktree readiness before rebuild",
86            );
87            return None;
88        }
89        if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
90            tracing::debug!(
91                path = %path.display(),
92                error = %error,
93                "could not remove stale reusable audit worktree before rebuild",
94            );
95            return None;
96        }
97        let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
98        if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
99            repo_root,
100            guard.path(),
101            base_sha,
102        ) {
103            tracing::debug!(
104                base_sha,
105                error = %error,
106                "could not materialize reusable audit base worktree",
107            );
108            return None;
109        }
110        // Deregister while keeping the directory, then atomically publish the
111        // full base SHA through the `.sha` sidecar. Publication happens only
112        // after successful materialization and deregistration under the lock,
113        // so a torn snapshot is never advertised as ready to the next run.
114        if let Err(error) = unregister_worktree_checked(repo_root, guard.path()) {
115            tracing::debug!(
116                path = %guard.path().display(),
117                error = %error,
118                "could not deregister reusable audit base worktree",
119            );
120            return None;
121        }
122        guard.defuse();
123        drop(guard);
124        let readiness_published = write_reusable_sha(&path, base_sha).is_ok();
125
126        let worktree = Self {
127            path,
128            persistent: true,
129            _reusable_lock: Some(reusable_lock),
130        };
131        materialize_base_dependency_context(repo_root, worktree.path());
132        if readiness_published {
133            touch_last_used(worktree.path());
134        }
135        Some(worktree)
136    }
137
138    pub fn path(&self) -> &Path {
139        &self.path
140    }
141}
142
143/// Build a unique temp path for a non-reusable base worktree.
144///
145/// The pid stays the FIRST `-`-separated segment so [`audit_worktree_pid`] and
146/// the orphan sweep keep working. A process-global monotonic counter is the
147/// final segment: the wall-clock nanos read is NOT monotonic and repeats across
148/// threads, so two `audit` runs in one process (e.g. parallel unit tests, or a
149/// future in-process batch) could otherwise mint the same path and race on
150/// `git worktree add`, where the loser fails and the audit aborts with a generic
151/// error. The counter makes every path distinct regardless of clock resolution.
152fn non_reusable_worktree_path() -> Option<PathBuf> {
153    static SEQ: AtomicU64 = AtomicU64::new(0);
154    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
155    let nanos = SystemTime::now()
156        .duration_since(SystemTime::UNIX_EPOCH)
157        .ok()?
158        .as_nanos();
159    Some(std::env::temp_dir().join(format!(
160        "fallow-audit-base-{}-{nanos}-{seq}",
161        std::process::id()
162    )))
163}
164
165/// RAII cleanup guard for a freshly-created git worktree directory.
166///
167/// Armed before the `git worktree add` subprocess runs. If the holder returns
168/// early (`?`) between subprocess success and the `BaseWorktree` struct binding,
169/// `Drop` rolls back BOTH git's `.git/worktrees/<name>` registration AND the
170/// on-disk directory. The owner calls `defuse()` once `BaseWorktree` is bound
171/// and takes over cleanup via its own `Drop`.
172///
173/// With `panic = "abort"` on the release profile, this does not provide
174/// panic-recovery cleanup (no unwind runs), but it is still load-bearing for
175/// every early-return path between subprocess success and struct construction.
176pub struct WorktreeCleanupGuard<'a> {
177    repo_root: PathBuf,
178    path: &'a Path,
179    armed: bool,
180}
181
182impl<'a> WorktreeCleanupGuard<'a> {
183    pub fn new(repo_root: &Path, path: &'a Path) -> Self {
184        Self {
185            repo_root: repo_root.to_path_buf(),
186            path,
187            armed: true,
188        }
189    }
190
191    pub fn path(&self) -> &Path {
192        self.path
193    }
194
195    /// Disarm in place. Idempotent; calling twice is harmless. Drop becomes a
196    /// no-op after this returns.
197    pub fn defuse(&mut self) {
198        self.armed = false;
199    }
200}
201
202impl Drop for WorktreeCleanupGuard<'_> {
203    fn drop(&mut self) {
204        if self.armed {
205            remove_audit_worktree(&self.repo_root, self.path);
206            let _ = std::fs::remove_dir_all(self.path);
207        }
208    }
209}
210
211/// Kernel-level advisory lock around the reusable-cache `reuse_or_create`
212/// critical section, backed by `std::fs::File::try_lock` (stable since Rust
213/// 1.89), which wraps `flock(2)` on Unix and `LockFileEx` on Windows.
214/// Concurrent acquirers either fall through (`None`) or observe a
215/// freshly-prepared cache after the holder releases.
216pub struct ReusableWorktreeLock {
217    file: std::fs::File,
218}
219
220impl ReusableWorktreeLock {
221    pub fn try_acquire(reusable_path: &Path) -> Option<Self> {
222        let lock_path = reusable_worktree_lock_path(reusable_path);
223        let file = open_or_create_owned_sidecar(&lock_path).ok()?;
224        match file.try_lock() {
225            Ok(()) => Some(Self { file }),
226            Err(std::fs::TryLockError::WouldBlock) => {
227                tracing::debug!(
228                    path = %lock_path.display(),
229                    "reusable audit worktree lock contended; falling back to non-reusable worktree",
230                );
231                None
232            }
233            Err(std::fs::TryLockError::Error(err)) => {
234                tracing::debug!(
235                    path = %lock_path.display(),
236                    error = %err,
237                    "could not acquire reusable audit worktree lock; falling back to non-reusable worktree",
238                );
239                None
240            }
241        }
242    }
243}
244
245impl Drop for ReusableWorktreeLock {
246    fn drop(&mut self) {
247        let _ = self.file.unlock();
248    }
249}
250
251pub fn reusable_worktree_lock_path(reusable_path: &Path) -> PathBuf {
252    sidecar_path(reusable_path, REUSABLE_LOCK_SUFFIX)
253}
254
255/// Build a sidecar path `<cache dir name><suffix>` next to (NOT inside) the
256/// reusable cache directory, so the sidecar survives `git worktree`
257/// operations and directory materialization on the cache dir itself.
258fn sidecar_path(reusable_path: &Path, suffix: &str) -> PathBuf {
259    let mut name = reusable_path
260        .file_name()
261        .map(std::ffi::OsString::from)
262        .unwrap_or_default();
263    name.push(suffix);
264    reusable_path
265        .parent()
266        .map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
267}
268
269/// Sidecar path recording the base SHA a reusable cache entry was
270/// materialized at. Lives next to the cache directory like the `.last-used` /
271/// `.lock` sidecars, so readiness can be verified without a `git` subprocess.
272pub fn reusable_worktree_sha_path(reusable_path: &Path) -> PathBuf {
273    sidecar_path(reusable_path, REUSABLE_SHA_SUFFIX)
274}
275
276/// Record the base SHA a reusable cache holds. Failure is non-fatal: this run
277/// proceeds and the next run rebuilds (a missing `.sha` reads as not-ready).
278fn write_reusable_sha(reusable_path: &Path, base_sha: &str) -> std::io::Result<()> {
279    static SEQ: AtomicU64 = AtomicU64::new(0);
280
281    let sha_path = reusable_worktree_sha_path(reusable_path);
282    let sequence = SEQ.fetch_add(1, Ordering::Relaxed);
283    let temp_path = sidecar_path(
284        reusable_path,
285        &format!(
286            "{REUSABLE_SHA_SUFFIX}.tmp-{}-{sequence}",
287            std::process::id()
288        ),
289    );
290    let result = (|| {
291        let mut options = std::fs::OpenOptions::new();
292        options.create_new(true).write(true);
293        #[cfg(unix)]
294        {
295            use std::os::unix::fs::OpenOptionsExt as _;
296            options.mode(0o600);
297        }
298        let mut file = options.open(&temp_path)?;
299        file.write_all(format!("{base_sha}\n").as_bytes())?;
300        file.sync_all()?;
301        std::fs::rename(&temp_path, &sha_path)
302    })();
303    if let Err(err) = &result {
304        let _ = std::fs::remove_file(&temp_path);
305        tracing::debug!(
306            path = %sha_path.display(),
307            error = %err,
308            "failed to write reusable audit worktree .sha sidecar; next run will rebuild",
309        );
310    }
311    result
312}
313
314/// Default GC threshold for persistent reusable base-snapshot caches.
315const DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS: u32 = 30;
316
317/// Env var that overrides `audit.cacheMaxAgeDays` from the config.
318const AUDIT_CACHE_MAX_AGE_ENV: &str = "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS";
319
320/// Sidecar filename suffix used to track last-use of a reusable worktree.
321const REUSABLE_LAST_USED_SUFFIX: &str = ".last-used";
322
323/// Sidecar filename suffix recording the base SHA a reusable cache holds.
324const REUSABLE_SHA_SUFFIX: &str = ".sha";
325
326/// Sidecar filename suffix of the reuse-lock file.
327const REUSABLE_LOCK_SUFFIX: &str = ".lock";
328
329/// Invalid gitdir pointer written into `<cache>/.git` after deregistering a
330/// transient audit worktree (issue #1815).
331///
332/// The `.git` file is REPLACED, never deleted: both discovery walkers use the
333/// `ignore` crate with `require_git` on, whose gitignore handling is gated on
334/// `<root>/.git` existing. Deleting the gitfile would silently stop the base
335/// pass from honoring `.gitignore`, inflating base findings and skewing
336/// audit's introduced-vs-inherited split. The stub keeps gitignore parity
337/// while pointing at a nonexistent gitdir, so any stray `git` command inside
338/// the snapshot fails loudly instead of operating on the host repo.
339const UNREGISTERED_GITDIR_STUB: &str = "gitdir: fallow-audit-unregistered\n";
340
341/// Sidecar path for the "last used" timestamp of a reusable cache entry.
342///
343/// Lives next to the cache directory (NOT inside it) so the sidecar is
344/// untouched by `git worktree add/remove` on the cache directory itself.
345pub fn reusable_worktree_last_used_path(reusable_path: &Path) -> PathBuf {
346    sidecar_path(reusable_path, REUSABLE_LAST_USED_SUFFIX)
347}
348
349/// Stamp the sidecar `.last-used` file's mtime to now.
350///
351/// Called on every cache-hit reuse (and from the pre-upgrade-grace branch
352/// of the GC sweep) so the staleness signal stays current even when the
353/// cache directory itself is not mutated. Failures are surfaced at
354/// `warn!` so a persistent ENOSPC / read-only-tmp condition is visible at
355/// default `RUST_LOG=warn`; the caller does not abort the audit.
356pub fn touch_last_used(reusable_path: &Path) {
357    let last_used = reusable_worktree_last_used_path(reusable_path);
358    let result = open_or_create_owned_sidecar(&last_used)
359        .and_then(|file| file.set_modified(SystemTime::now()));
360    if let Err(err) = result {
361        tracing::warn!(
362            path = %last_used.display(),
363            error = %err,
364            "failed to touch reusable audit worktree sidecar; staleness signal may not update",
365        );
366    }
367}
368
369fn open_or_create_owned_sidecar(path: &Path) -> std::io::Result<std::fs::File> {
370    match std::fs::symlink_metadata(path) {
371        Ok(metadata) if sidecar_metadata_is_trusted(&metadata) => {
372            std::fs::OpenOptions::new().write(true).open(path)
373        }
374        Ok(_) => Err(std::io::Error::new(
375            std::io::ErrorKind::PermissionDenied,
376            "refusing to open an untrusted audit cache sidecar",
377        )),
378        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
379            let mut options = std::fs::OpenOptions::new();
380            options.create_new(true).write(true);
381            #[cfg(unix)]
382            {
383                use std::os::unix::fs::OpenOptionsExt as _;
384                options.mode(0o600);
385            }
386            options.open(path)
387        }
388        Err(error) => Err(error),
389    }
390}
391
392#[cfg(unix)]
393fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
394    use std::os::unix::fs::MetadataExt as _;
395
396    metadata_is_regular_file(metadata) && metadata.uid() == rustix::process::geteuid().as_raw()
397}
398
399#[cfg(not(unix))]
400fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
401    metadata_is_regular_file(metadata)
402}
403
404#[expect(
405    clippy::filetype_is_file,
406    reason = "security-sensitive sidecars and gitfiles must be regular files, not arbitrary non-directories"
407)]
408fn metadata_is_regular_file(metadata: &std::fs::Metadata) -> bool {
409    metadata.file_type().is_file()
410}
411
412/// Resolve the GC threshold for persistent reusable caches.
413///
414/// Precedence: `FALLOW_AUDIT_CACHE_MAX_AGE_DAYS` env var > `audit.cacheMaxAgeDays`
415/// config field > 30-day default. `0` from either source disables the sweep
416/// entirely (returns `None`). Invalid env values (non-integer) silently fall
417/// back to config / default; audits do not fail on a typo in a runner env var.
418pub fn resolve_cache_max_age_with_options(
419    root: &Path,
420    config_path: Option<&PathBuf>,
421    allow_remote_extends: bool,
422) -> Option<Duration> {
423    if let Ok(raw) = std::env::var(AUDIT_CACHE_MAX_AGE_ENV) {
424        if let Ok(days) = raw.trim().parse::<u32>() {
425            return days_to_duration(days);
426        }
427        tracing::debug!(
428            value = %raw,
429            "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS is not a valid u32; falling back to config/default",
430        );
431    }
432    if let Some(days) = load_audit_config(root, config_path, allow_remote_extends)
433        .and_then(|c| c.cache_max_age_days)
434    {
435        return days_to_duration(days);
436    }
437    days_to_duration(DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS)
438}
439
440pub fn days_to_duration(days: u32) -> Option<Duration> {
441    if days == 0 {
442        return None;
443    }
444    Some(Duration::from_secs(u64::from(days) * 86_400))
445}
446
447/// Load `AuditConfig` from `opts.config_path` (or auto-discover from
448/// `opts.root`) for GC-threshold resolution only. Errors silently fall
449/// back to `None`; the caller defaults to a 30-day window.
450fn load_audit_config(
451    root: &Path,
452    config_path: Option<&PathBuf>,
453    allow_remote_extends: bool,
454) -> Option<fallow_config::AuditConfig> {
455    let options = fallow_config::ConfigLoadOptions {
456        allow_remote_extends,
457    };
458    if let Some(path) = config_path {
459        return fallow_config::FallowConfig::load_with_options(path, options)
460            .ok()
461            .map(|config| config.audit);
462    }
463    fallow_config::FallowConfig::find_and_load_with_options(root, options)
464        .ok()
465        .flatten()
466        .map(|(config, _path)| config.audit)
467}
468
469/// Reclaim persistent reusable base-snapshot worktree caches.
470///
471/// Two reclaim conditions, checked per entry:
472/// - Prunable orphan: the cache directory no longer exists (an external
473///   `$TMPDIR` reaper, a container restart, or a CI cache eviction deleted it
474///   but left git's admin entry behind). Reclaimed eagerly, independent of
475///   `max_age`, because the `.last-used` sidecar lives next to the deleted
476///   directory and survives the reaper, so the age branch would re-touch a
477///   fresh sidecar and never reclaim the dead entry. Passing `max_age = None`
478///   (age-based GC disabled) still runs this reclaim.
479/// - Aged-out: the sidecar `.last-used` file is older than `max_age` (only
480///   when `max_age` is `Some`).
481///
482/// Concurrency: each candidate is gated by [`ReusableWorktreeLock`] before
483/// removal, so an in-flight `fallow audit` mid-rebuild against the same
484/// cache entry will not be disturbed (the sweep skips on contention). The
485/// orphan branch re-checks existence under the lock so a rebuild that
486/// recreated the directory between the check and the lock is preserved.
487///
488/// Pre-upgrade caches lacking a sidecar are NOT removed: instead the sweep
489/// seeds a fresh sidecar so the next invocation can age them from real
490/// last-use. Without this grace, the dir's own mtime (= creation date on
491/// POSIX) would wipe every legitimately-warm pre-upgrade cache on the
492/// first run after upgrade.
493///
494/// The `.lock` sidecar file is intentionally NOT deleted on removal: a
495/// racing acquirer of an unlinked-but-still-flocked inode plus a sibling
496/// `open(O_CREAT)` at the same path would produce two processes each
497/// holding a kernel flock on different inodes. Lock files are tens of
498/// bytes; leaking them is harmless.
499pub fn sweep_old_reusable_caches(repo_root: &Path, max_age: Option<Duration>, quiet: bool) {
500    // Legacy pass: deregister reusable caches left REGISTERED by pre-#1815
501    // fallow (the reporter's `git worktree list` backlog). This is what makes
502    // those entries vanish on the first post-upgrade audit; the `--expire=now`
503    // prune is retained ONLY here to also sweep any admin entry orphaned by a
504    // crash in the transient registration window.
505    if deregister_legacy_reusable_caches(repo_root) {
506        let mut command = Command::new("git");
507        command
508            .args(["worktree", "prune", "--expire=now"])
509            .current_dir(repo_root);
510        clear_ambient_git_env(&mut command);
511        let _ = command.output();
512    }
513
514    // Primary pass: visit this requested root's cache plus legacy SHA-suffixed
515    // entries from the old git-top-level identity. `git worktree list` no
516    // longer sees the deregistered caches.
517    let mut paths = vec![reusable_audit_worktree_path(repo_root)];
518    paths.extend(scan_legacy_reusable_cache_paths(repo_root));
519    paths.sort();
520    paths.dedup();
521    let now = SystemTime::now();
522    let mut removed: u32 = 0;
523    for path in paths {
524        if reclaim_reusable_cache_entry(repo_root, &path, max_age, now) {
525            removed += 1;
526        }
527    }
528    if removed == 0 {
529        return;
530    }
531    tracing::info!(
532        count = removed,
533        "reclaimed stale audit base-snapshot caches",
534    );
535    if !quiet {
536        let s = plural(removed as usize);
537        let _ = writeln!(
538            std::io::stderr(),
539            "fallow: reclaimed {removed} stale base-snapshot cache{s}",
540        );
541    }
542}
543
544/// Deregister reusable base-snapshot caches left REGISTERED by fallow versions
545/// before #1815. A mixed-version registration at the current path stays warm;
546/// released SHA-keyed paths are removed because the root-owned cache cannot
547/// reuse them. Returns `true` when at least one entry was deregistered.
548fn deregister_legacy_reusable_caches(repo_root: &Path) -> bool {
549    let Some(worktrees) = list_audit_worktrees(repo_root) else {
550        return false;
551    };
552    let mut deregistered = false;
553    for path in worktrees {
554        if !is_reusable_audit_worktree_path(&path) {
555            continue;
556        }
557        let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
558            continue;
559        };
560        if !audit_worktree_is_registered(repo_root, &path) {
561            continue;
562        }
563        let is_current_path = paths_equal(&path, &reusable_audit_worktree_path(repo_root));
564        let head = is_current_path
565            .then(|| legacy_reusable_sha(&path))
566            .flatten();
567        if unregister_worktree_checked(repo_root, &path).is_err() {
568            continue;
569        }
570        if is_current_path {
571            if let Some(head) = head {
572                let _ = write_reusable_sha(&path, &head);
573            }
574        } else if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
575            tracing::warn!(
576                path = %path.display(),
577                error = %error,
578                "failed to remove released SHA-keyed audit cache",
579            );
580        }
581        deregistered = true;
582    }
583    deregistered
584}
585
586/// Seed the `.sha` sidecar for a still-registered legacy cache from its HEAD,
587/// so after deregistration the readiness probe recognizes it as warm. Seeds
588/// only when the snapshot was raw-materialized and no `.sha` exists yet.
589fn legacy_reusable_sha(path: &Path) -> Option<String> {
590    if reusable_worktree_sha_path(path).exists()
591        || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
592    {
593        return None;
594    }
595    git_rev_parse(path, "HEAD")
596}
597
598/// Enumerate reusable cache DIRECTORY paths for `prefix` by scanning the temp
599/// dir. Sidecar entries (`.last-used` / `.sha` / `.lock`) are folded back to
600/// their owning cache path and deduplicated, so a dir removed out from under
601/// its sidecars is still visited for sidecar-orphan cleanup.
602fn scan_legacy_reusable_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
603    let Some(prefix) = legacy_reusable_cache_repo_prefix(repo_root) else {
604        return Vec::new();
605    };
606    scan_cache_paths_with_hex_suffix(&prefix)
607}
608
609fn scan_root_owned_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
610    let Some(prefix) = root_owned_cache_repo_prefix(repo_root) else {
611        return Vec::new();
612    };
613    scan_cache_paths_with_hex_suffix(&prefix)
614}
615
616fn scan_cache_paths_with_hex_suffix(prefix: &str) -> Vec<PathBuf> {
617    let temp = std::env::temp_dir();
618    let Ok(entries) = std::fs::read_dir(&temp) else {
619        return Vec::new();
620    };
621    let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
622    let mut paths = Vec::new();
623    for entry in entries.flatten() {
624        let name = entry.file_name();
625        let Some(name) = name.to_str() else {
626            continue;
627        };
628        let cache_name = strip_cache_sidecar_suffix(name);
629        let Some(hash_suffix) = cache_name.strip_prefix(prefix) else {
630            continue;
631        };
632        if hash_suffix.len() != 16 || !hash_suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
633            continue;
634        }
635        let path = temp.join(cache_name);
636        if seen.insert(path.clone()) {
637            paths.push(path);
638        }
639    }
640    paths
641}
642
643/// Strip a known reusable-cache sidecar suffix so a sidecar entry maps to its
644/// owning cache directory name. Cache dir names end in a hex SHA prefix and
645/// never contain these suffixes, so the mapping is unambiguous.
646fn strip_cache_sidecar_suffix(name: &str) -> &str {
647    for suffix in [
648        REUSABLE_LAST_USED_SUFFIX,
649        REUSABLE_SHA_SUFFIX,
650        REUSABLE_LOCK_SUFFIX,
651    ] {
652        if let Some(stripped) = name.strip_suffix(suffix) {
653            return stripped;
654        }
655    }
656    name
657}
658
659/// Reclaim a single reusable-cache entry. Returns `true` when the entry was
660/// removed (either as a sidecar orphan or as aged-out past `max_age`).
661fn reclaim_reusable_cache_entry(
662    repo_root: &Path,
663    path: &Path,
664    max_age: Option<Duration>,
665    now: SystemTime,
666) -> bool {
667    // Sidecar orphan: an external temp-reaper (macOS `$TMPDIR` cleanup,
668    // container restart, CI cache eviction) removed the cache directory but
669    // its sidecars survive next to it. Reclaim the leftover `.last-used` /
670    // `.sha` eagerly, independent of `max_age`, so orphans do not accumulate
671    // even when age-based GC is disabled (`cacheMaxAgeDays = 0`). This also
672    // fixes a leak that predates #1815: a manual `git worktree remove` left
673    // sidecars the old git-scoped sweep could never see.
674    if !path.exists() {
675        return reclaim_orphan_cache_entry(repo_root, path);
676    }
677    let Some(max_age) = max_age else {
678        return false;
679    };
680    reclaim_aged_cache_entry(repo_root, path, max_age, now)
681}
682
683/// Reclaim the leftover sidecars of a cache directory that was deleted out
684/// from under them. Lock-guarded with a re-check so a concurrent rebuild that
685/// recreated the directory is preserved. The `.lock` sidecar is deliberately
686/// never removed (an unlinked-but-flocked inode plus a racer's `open(O_CREAT)`
687/// would split the lock across two inodes).
688fn reclaim_orphan_cache_entry(repo_root: &Path, path: &Path) -> bool {
689    let Some(_lock) = ReusableWorktreeLock::try_acquire(path) else {
690        return false;
691    };
692    // Re-check under the lock: a concurrent `reuse_or_create` rebuild may
693    // have recreated the directory between the existence check and the lock.
694    if path.exists() {
695        return false;
696    }
697    remove_reusable_cache_entry_locked(repo_root, path).unwrap_or(false)
698}
699
700/// Reclaim a cache entry whose `.last-used` sidecar is older than `max_age`.
701/// Seeds a fresh sidecar for pre-upgrade entries that lack one (returns
702/// `false` so they age from real last-use on the next run). Removal is
703/// directory-and-sidecars only: the entry is unregistered, so no `git`
704/// subprocess is involved.
705fn reclaim_aged_cache_entry(
706    repo_root: &Path,
707    path: &Path,
708    max_age: Duration,
709    now: SystemTime,
710) -> bool {
711    let sidecar = reusable_worktree_last_used_path(path);
712    let sidecar_mtime = std::fs::metadata(&sidecar)
713        .ok()
714        .and_then(|m| m.modified().ok());
715    let Some(mtime) = sidecar_mtime else {
716        touch_last_used(path);
717        return false;
718    };
719    let Ok(age) = now.duration_since(mtime) else {
720        return false;
721    };
722    if age < max_age {
723        return false;
724    }
725    let Some(_lock) = ReusableWorktreeLock::try_acquire(path) else {
726        return false;
727    };
728    match remove_reusable_cache_entry_locked(repo_root, path) {
729        Ok(removed) => removed,
730        Err(err) => {
731            tracing::warn!(
732                path = %path.display(),
733                error = %err,
734                "failed to remove stale reusable audit worktree entry; entry may leak",
735            );
736            false
737        }
738    }
739}
740
741pub fn canonical_root_hash(root: &Path) -> u64 {
742    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
743    xxh3_64(&path_identity_bytes(&canonical_root))
744}
745
746#[cfg(unix)]
747fn path_identity_bytes(path: &Path) -> Vec<u8> {
748    use std::os::unix::ffi::OsStrExt as _;
749
750    path.as_os_str().as_bytes().to_vec()
751}
752
753#[cfg(windows)]
754fn path_identity_bytes(path: &Path) -> Vec<u8> {
755    use std::os::windows::ffi::OsStrExt as _;
756
757    path.as_os_str()
758        .encode_wide()
759        .flat_map(u16::to_le_bytes)
760        .collect()
761}
762
763#[cfg(not(any(unix, windows)))]
764fn path_identity_bytes(path: &Path) -> Vec<u8> {
765    path.to_string_lossy().as_bytes().to_vec()
766}
767
768pub fn reusable_audit_worktree_path(requested_root: &Path) -> PathBuf {
769    let root_hash = canonical_root_hash(requested_root);
770    let repo_hash = git_toplevel(requested_root)
771        .as_deref()
772        .map_or(root_hash, canonical_root_hash);
773    std::env::temp_dir().join(format!(
774        "fallow-audit-base-cache-{repo_hash:016x}-root-{root_hash:016x}"
775    ))
776}
777
778fn root_owned_cache_repo_prefix(requested_root: &Path) -> Option<String> {
779    let git_root = git_toplevel(requested_root)?;
780    let repo_hash = canonical_root_hash(&git_root);
781    Some(format!("fallow-audit-base-cache-{repo_hash:016x}-root-"))
782}
783
784fn legacy_reusable_cache_repo_prefix(requested_root: &Path) -> Option<String> {
785    let git_root = git_toplevel(requested_root)?;
786    let repo_hash = canonical_root_hash(&git_root);
787    Some(format!("fallow-audit-base-cache-{repo_hash:016x}-"))
788}
789
790#[cfg(test)]
791pub fn legacy_reusable_audit_worktree_path(
792    requested_root: &Path,
793    base_sha: &str,
794) -> Option<PathBuf> {
795    let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
796    Some(std::env::temp_dir().join(format!(
797        "{}{sha_prefix}",
798        legacy_reusable_cache_repo_prefix(requested_root)?
799    )))
800}
801
802/// Readiness for a reusable cache HIT: the directory exists and its `.sha`
803/// sidecar records exactly `base_sha`.
804///
805/// Fidelity is equivalent to the old in-worktree `git rev-parse HEAD` probe:
806/// that probe read the host admin dir's HEAD, never the snapshot's on-disk
807/// content, so neither approach detects content damage. The `.sha` is only
808/// ever written after a successful materialization + deregistration, so a
809/// torn snapshot never presents a matching sidecar. On a hit the `.git` stub
810/// is repaired idempotently so gitignore parity holds even if the stub was
811/// removed out-of-band.
812fn reusable_audit_worktree_is_ready(path: &Path, base_sha: &str) -> bool {
813    if !reusable_cache_directory_is_trusted(path) {
814        return false;
815    }
816    let recorded = read_reusable_sha(path);
817    if recorded.as_deref() != Some(base_sha) {
818        return false;
819    }
820    repair_unregistered_git_stub(path)
821}
822
823fn read_reusable_sha(path: &Path) -> Option<String> {
824    const MAX_SHA_SIDECAR_BYTES: u64 = 129;
825
826    let sidecar = reusable_worktree_sha_path(path);
827    let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
828    if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_SHA_SIDECAR_BYTES {
829        return None;
830    }
831    let mut contents = String::new();
832    std::fs::File::open(sidecar)
833        .ok()?
834        .take(MAX_SHA_SIDECAR_BYTES)
835        .read_to_string(&mut contents)
836        .ok()?;
837    Some(contents.trim().to_owned())
838}
839
840#[cfg(unix)]
841fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
842    use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
843
844    let Ok(metadata) = std::fs::symlink_metadata(path) else {
845        return false;
846    };
847    metadata.file_type().is_dir()
848        && metadata.uid() == rustix::process::geteuid().as_raw()
849        && metadata.permissions().mode().trailing_zeros() >= 6
850}
851
852#[cfg(not(unix))]
853fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
854    std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
855}
856
857/// Recover a current-path cache that is still a registered Git worktree.
858///
859/// This can occur during mixed-version use or after interruption between
860/// registration and deregistration. Keep the cache warm only when HEAD matches
861/// the requested full SHA and raw materialization completed.
862fn try_migrate_registered_current_cache(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
863    if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
864        return false;
865    }
866    let head_matches = git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha);
867    if !head_matches || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
868    {
869        return false;
870    }
871    if unregister_worktree_checked(repo_root, path).is_err() {
872        return false;
873    }
874    write_reusable_sha(path, base_sha).is_ok()
875}
876
877/// Remove every reusable base-snapshot cache owned by `requested_root`.
878///
879/// The root-owned entry and every SHA-suffixed entry from the old
880/// git-top-level identity are locked independently. Contended entries are
881/// reported as skipped. Lock files are permanent lock identities and are never
882/// removed.
883#[derive(Debug, Clone, Copy, PartialEq, Eq)]
884pub struct AuditCacheRemovalReport {
885    pub found: usize,
886    pub removed: usize,
887    pub skipped: usize,
888    pub dry_run: bool,
889}
890
891pub fn remove_reusable_audit_caches(
892    requested_root: &Path,
893    dry_run: bool,
894) -> std::io::Result<AuditCacheRemovalReport> {
895    let mut paths = vec![reusable_audit_worktree_path(requested_root)];
896    paths.extend(scan_legacy_reusable_cache_paths(requested_root));
897    if git_toplevel(requested_root).is_some_and(|root| paths_equal(&root, requested_root)) {
898        paths.extend(scan_root_owned_cache_paths(requested_root));
899    }
900    paths.sort();
901    paths.dedup();
902
903    let mut report = AuditCacheRemovalReport {
904        found: 0,
905        removed: 0,
906        skipped: 0,
907        dry_run,
908    };
909    for path in paths {
910        if !reusable_cache_entry_exists(&path) {
911            continue;
912        }
913        report.found += 1;
914        if dry_run {
915            // A preview must not touch the filesystem. Acquiring the lock would
916            // create the `.lock` sidecar (create_new) for entries that lack one,
917            // violating the documented --dry-run contract. Contention is a race
918            // only the real removal reports.
919            continue;
920        }
921        let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
922            report.skipped += 1;
923            continue;
924        };
925        if remove_reusable_cache_entry_locked(requested_root, &path)? {
926            report.removed += 1;
927        }
928    }
929    Ok(report)
930}
931
932fn reusable_cache_entry_exists(path: &Path) -> bool {
933    path_entry_exists(path)
934        || path_entry_exists(&reusable_worktree_sha_path(path))
935        || path_entry_exists(&reusable_worktree_last_used_path(path))
936}
937
938fn path_entry_exists(path: &Path) -> bool {
939    std::fs::symlink_metadata(path).is_ok()
940}
941
942/// Remove one reusable cache while its caller holds the entry's exclusive
943/// lock. Absence is success. The lock sidecar is deliberately preserved.
944fn remove_reusable_cache_entry_locked(repo_root: &Path, path: &Path) -> std::io::Result<bool> {
945    let existed = reusable_cache_entry_exists(path);
946    ensure_cache_entry_is_owned(path)?;
947    if trusted_worktree_admin_dir(repo_root, path).is_some() {
948        unregister_worktree_checked(repo_root, path)?;
949    }
950    remove_dir_if_exists(path)?;
951    remove_file_if_exists(&reusable_worktree_sha_path(path))?;
952    remove_file_if_exists(&reusable_worktree_last_used_path(path))?;
953    Ok(existed)
954}
955
956#[cfg(unix)]
957fn ensure_cache_entry_is_owned(path: &Path) -> std::io::Result<()> {
958    use std::os::unix::fs::MetadataExt as _;
959
960    let effective_uid = rustix::process::geteuid().as_raw();
961    for entry in [
962        path.to_path_buf(),
963        reusable_worktree_sha_path(path),
964        reusable_worktree_last_used_path(path),
965    ] {
966        let metadata = match std::fs::symlink_metadata(&entry) {
967            Ok(metadata) => metadata,
968            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
969            Err(error) => return Err(error),
970        };
971        if metadata.uid() != effective_uid {
972            return Err(std::io::Error::new(
973                std::io::ErrorKind::PermissionDenied,
974                format!(
975                    "refusing to remove unowned audit cache entry `{}`",
976                    entry.display()
977                ),
978            ));
979        }
980    }
981    Ok(())
982}
983
984#[cfg(not(unix))]
985#[expect(
986    clippy::unnecessary_wraps,
987    reason = "shared cross-platform signature; the Unix ownership check is fallible, non-Unix has no POSIX owner to verify"
988)]
989fn ensure_cache_entry_is_owned(_path: &Path) -> std::io::Result<()> {
990    Ok(())
991}
992
993fn remove_dir_if_exists(path: &Path) -> std::io::Result<()> {
994    match std::fs::remove_dir_all(path) {
995        Ok(()) => Ok(()),
996        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
997        Err(err) => Err(err),
998    }
999}
1000
1001fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
1002    match std::fs::remove_file(path) {
1003        Ok(()) => Ok(()),
1004        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1005        Err(err) => Err(err),
1006    }
1007}
1008
1009/// Deregister a freshly-added audit worktree from git while KEEPING its
1010/// directory on disk (issue #1815).
1011///
1012/// Targets the single admin dir this worktree owns via its `.git` gitfile
1013/// pointer, rather than a global `git worktree prune`, so a user's unrelated
1014/// prunable worktrees are never collaterally deregistered and git's
1015/// name-collision admin suffixing (`<name>1`) is handled for free. The `.git`
1016/// gitfile is REPLACED with an invalid stub (see [`UNREGISTERED_GITDIR_STUB`]),
1017/// never deleted.
1018pub fn unregister_worktree(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1019    unregister_worktree_checked(repo_root, path)
1020}
1021
1022fn unregister_worktree_checked(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1023    if !path.exists() {
1024        return Ok(());
1025    }
1026    let gitfile = path.join(".git");
1027    let metadata = std::fs::symlink_metadata(&gitfile)?;
1028    if !metadata_is_regular_file(&metadata) {
1029        return Err(std::io::Error::new(
1030            std::io::ErrorKind::InvalidData,
1031            "refusing to deregister through a non-file audit worktree .git entry",
1032        ));
1033    }
1034    let contents = std::fs::read_to_string(&gitfile)?;
1035    if contents == UNREGISTERED_GITDIR_STUB {
1036        return Ok(());
1037    }
1038    let Some(admin_dir) = trusted_worktree_admin_dir(repo_root, path) else {
1039        return Err(std::io::Error::new(
1040            std::io::ErrorKind::InvalidData,
1041            "refusing to deregister an unverified audit worktree admin entry",
1042        ));
1043    };
1044    remove_dir_if_exists(&admin_dir)?;
1045    write_git_stub_safely(&gitfile)
1046}
1047
1048/// Ensure a cache hit's `.git` stub stays valid. Recreates the stub if the
1049/// gitfile is gone (so gitignore parity holds), and deregisters in place if a
1050/// live pointer to a fallow admin dir is found (a mixed-version re-registration
1051/// or a torn transient window).
1052fn repair_unregistered_git_stub(path: &Path) -> bool {
1053    let gitfile = path.join(".git");
1054    let Ok(metadata) = std::fs::symlink_metadata(&gitfile) else {
1055        return write_git_stub_safely(&gitfile).is_ok();
1056    };
1057    if !metadata_is_regular_file(&metadata) {
1058        return false;
1059    }
1060    std::fs::read_to_string(&gitfile).is_ok_and(|contents| contents == UNREGISTERED_GITDIR_STUB)
1061}
1062
1063fn write_git_stub_safely(gitfile: &Path) -> std::io::Result<()> {
1064    let mut options = std::fs::OpenOptions::new();
1065    options.write(true).truncate(true);
1066    match std::fs::symlink_metadata(gitfile) {
1067        Ok(metadata) if metadata_is_regular_file(&metadata) => {}
1068        Ok(_) => {
1069            return Err(std::io::Error::new(
1070                std::io::ErrorKind::InvalidData,
1071                "refusing to replace non-file audit worktree .git entry",
1072            ));
1073        }
1074        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1075            options.create_new(true);
1076        }
1077        Err(error) => return Err(error),
1078    }
1079    let mut file = options.open(gitfile)?;
1080    file.write_all(UNREGISTERED_GITDIR_STUB.as_bytes())?;
1081    file.sync_all()
1082}
1083
1084fn trusted_worktree_admin_dir(repo_root: &Path, path: &Path) -> Option<PathBuf> {
1085    let gitfile = path.join(".git");
1086    let metadata = std::fs::symlink_metadata(&gitfile).ok()?;
1087    if !metadata_is_regular_file(&metadata) {
1088        return None;
1089    }
1090    let contents = std::fs::read_to_string(&gitfile).ok()?;
1091    let admin_dir = parse_worktree_gitdir(&contents)?;
1092    if !is_fallow_admin_dir(&admin_dir) {
1093        return None;
1094    }
1095    let common_dir = fallow_engine::changed_files::resolve_git_common_dir(repo_root).ok()?;
1096    let worktrees_dir = dunce::canonicalize(common_dir.join("worktrees")).ok()?;
1097    let admin_parent = dunce::canonicalize(admin_dir.parent()?).ok()?;
1098    if admin_parent != worktrees_dir {
1099        return None;
1100    }
1101    let backlink = std::fs::read_to_string(admin_dir.join("gitdir")).ok()?;
1102    let expected_gitfile = dunce::canonicalize(&gitfile).ok()?;
1103    let actual_gitfile = dunce::canonicalize(Path::new(backlink.trim())).ok()?;
1104    (actual_gitfile == expected_gitfile).then_some(admin_dir)
1105}
1106
1107/// Parse the `gitdir: <path>` pointer from a linked-worktree `.git` gitfile.
1108fn parse_worktree_gitdir(contents: &str) -> Option<PathBuf> {
1109    contents
1110        .lines()
1111        .find_map(|line| line.trim().strip_prefix("gitdir:"))
1112        .map(|rest| PathBuf::from(rest.trim()))
1113}
1114
1115/// True when an admin dir basename is one fallow itself created, used as a
1116/// safety belt before removing it.
1117fn is_fallow_admin_dir(admin_dir: &Path) -> bool {
1118    admin_dir
1119        .file_name()
1120        .and_then(|name| name.to_str())
1121        .is_some_and(|name| name.starts_with("fallow-audit-base-"))
1122}
1123
1124pub fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
1125    let mut command = Command::new("git");
1126    command.args(["rev-parse", rev]).current_dir(root);
1127    clear_ambient_git_env(&mut command);
1128    let output = command.output().ok()?;
1129    if !output.status.success() {
1130        return None;
1131    }
1132    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1133}
1134
1135pub fn git_toplevel(root: &Path) -> Option<PathBuf> {
1136    let mut command = Command::new("git");
1137    command
1138        .args(["rev-parse", "--show-toplevel"])
1139        .current_dir(root);
1140    clear_ambient_git_env(&mut command);
1141    let output = command.output().ok()?;
1142    if !output.status.success() {
1143        return None;
1144    }
1145    let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1146    Some(dunce::canonicalize(&path).unwrap_or(path))
1147}
1148
1149fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
1150    let Some(worktrees) = list_audit_worktrees(repo_root) else {
1151        return false;
1152    };
1153    worktrees.iter().any(|worktree| paths_equal(worktree, path))
1154}
1155
1156pub fn paths_equal(left: &Path, right: &Path) -> bool {
1157    if left == right {
1158        return true;
1159    }
1160    match (dunce::canonicalize(left), dunce::canonicalize(right)) {
1161        (Ok(left), Ok(right)) => left == right,
1162        _ => false,
1163    }
1164}
1165
1166/// Directories the audit base worktree shares with the host checkout.
1167///
1168/// `node_modules` is the original case: bare `git worktree add` lacks the
1169/// installed dependencies. `.nuxt` / `.astro` extend the same idea to
1170/// meta-framework `prepare` / `sync` outputs that the project gitignores;
1171/// without them the base pass cannot resolve tsconfig `references` chains
1172/// pointing into the generated tsconfigs and falls back to resolver-less
1173/// resolution. The trade-off matches `node_modules`: the symlinked dir is
1174/// HEAD-shaped, not base-shaped, but the alias resolution accuracy recovered
1175/// far outweighs the residual drift.
1176///
1177/// The meta-framework entries must stay aligned with the set recognized by
1178/// `missing_meta_framework_prerequisites` in `fallow_core`'s plugin registry.
1179/// Adding a framework's prepare-dir warning there without extending this list
1180/// silently reintroduces the broken-tsconfig-chain bug on the base pass for
1181/// that framework.
1182const MATERIALIZED_CONTEXT_DIRS: &[&str] = &["node_modules", ".nuxt", ".astro"];
1183
1184pub fn materialize_base_dependency_context(repo_root: &Path, worktree_path: &Path) {
1185    for &name in MATERIALIZED_CONTEXT_DIRS {
1186        let source = repo_root.join(name);
1187        if !source.is_dir() {
1188            continue;
1189        }
1190
1191        let destination = worktree_path.join(name);
1192        if destination.is_dir() {
1193            continue;
1194        }
1195        if let Ok(metadata) = std::fs::symlink_metadata(&destination) {
1196            if !metadata.file_type().is_symlink() {
1197                continue;
1198            }
1199            let _ = std::fs::remove_file(&destination);
1200        }
1201
1202        let _ = symlink_dependency_dir(&source, &destination);
1203    }
1204}
1205
1206#[cfg(unix)]
1207fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
1208    std::os::unix::fs::symlink(source, destination)
1209}
1210
1211#[cfg(windows)]
1212fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
1213    std::os::windows::fs::symlink_dir(source, destination)
1214}
1215
1216pub fn remove_audit_worktree(repo_root: &Path, path: &Path) {
1217    let mut command = Command::new("git");
1218    command
1219        .args([
1220            "worktree",
1221            "remove",
1222            "--force",
1223            path.to_string_lossy().as_ref(),
1224        ])
1225        .current_dir(repo_root);
1226    clear_ambient_git_env(&mut command);
1227    match crate::signal::scoped_child::output(&mut command) {
1228        Ok(output) => {
1229            if !output.status.success() && path.exists() {
1230                let stderr = String::from_utf8_lossy(&output.stderr);
1231                tracing::warn!(
1232                    path = %path.display(),
1233                    stderr = %stderr.trim(),
1234                    "git worktree remove failed; the directory remains and may leak",
1235                );
1236            }
1237        }
1238        Err(err) => {
1239            tracing::warn!(
1240                path = %path.display(),
1241                error = %err,
1242                "git worktree remove subprocess failed to spawn",
1243            );
1244        }
1245    }
1246}
1247
1248pub fn sweep_orphan_audit_worktrees(repo_root: &Path) {
1249    sweep_orphan_audit_worktrees_in(repo_root, &std::env::temp_dir());
1250}
1251
1252/// `temp_root` is the directory scanned for unregistered dead-pid worktree
1253/// directories. Production always passes `std::env::temp_dir()`; tests pass a
1254/// private root because a fabricated dead-pid fixture in the SHARED temp dir
1255/// is legitimate prey for any concurrent sweep (a parallel test or a spawned
1256/// fallow binary), which races the fixture's own assertions.
1257pub fn sweep_orphan_audit_worktrees_in(repo_root: &Path, temp_root: &Path) {
1258    // Legacy pass: deregister dead-pid non-reusable worktrees left REGISTERED
1259    // by pre-#1815 fallow (or by a crash in the transient registration
1260    // window). The `--expire=now` prune, retained only here, also sweeps any
1261    // now-dangling admin entry.
1262    if deregister_legacy_orphan_worktrees(repo_root) {
1263        let mut command = Command::new("git");
1264        command
1265            .args(["worktree", "prune", "--expire=now"])
1266            .current_dir(repo_root);
1267        clear_ambient_git_env(&mut command);
1268        let _ = command.output();
1269    }
1270
1271    // Primary pass: remove unregistered dead-pid worktree DIRECTORIES via a
1272    // temp-dir prefix scan. A dead PID means the owning process is gone
1273    // regardless of repo, so this scan is global (not repo-scoped).
1274    for path in scan_non_reusable_orphan_paths(temp_root) {
1275        let _ = std::fs::remove_dir_all(&path);
1276    }
1277}
1278
1279/// Deregister dead-pid non-reusable worktrees left REGISTERED by pre-#1815
1280/// fallow or by a crash mid-registration. Returns `true` when any were removed.
1281fn deregister_legacy_orphan_worktrees(repo_root: &Path) -> bool {
1282    let Some(worktrees) = list_audit_worktrees(repo_root) else {
1283        return false;
1284    };
1285    let mut removed_any = false;
1286    for path in worktrees {
1287        if !is_fallow_audit_worktree_path(&path)
1288            || is_reusable_audit_worktree_path(&path)
1289            || audit_worktree_process_is_alive(&path)
1290        {
1291            continue;
1292        }
1293        remove_audit_worktree(repo_root, &path);
1294        let _ = std::fs::remove_dir_all(&path);
1295        removed_any = true;
1296    }
1297    removed_any
1298}
1299
1300/// Enumerate unregistered non-reusable worktree DIRECTORY paths owned by a
1301/// dead PID. Reusable caches yield no parseable PID and are skipped.
1302fn scan_non_reusable_orphan_paths(temp: &Path) -> Vec<PathBuf> {
1303    let Ok(entries) = std::fs::read_dir(temp) else {
1304        return Vec::new();
1305    };
1306    let mut paths = Vec::new();
1307    for entry in entries.flatten() {
1308        let name = entry.file_name();
1309        let Some(name) = name.to_str() else {
1310            continue;
1311        };
1312        let Some(pid) = audit_worktree_pid(name) else {
1313            continue;
1314        };
1315        if process_is_alive(pid) || !entry.path().is_dir() {
1316            continue;
1317        }
1318        paths.push(temp.join(name));
1319    }
1320    paths
1321}
1322
1323pub fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
1324    let mut command = Command::new("git");
1325    command
1326        .args(["worktree", "list", "--porcelain"])
1327        .current_dir(repo_root);
1328    clear_ambient_git_env(&mut command);
1329    let output = command.output().ok()?;
1330    if !output.status.success() {
1331        return None;
1332    }
1333    Some(parse_worktree_list(&String::from_utf8_lossy(
1334        &output.stdout,
1335    )))
1336}
1337
1338pub fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
1339    output
1340        .lines()
1341        .filter_map(|line| line.strip_prefix("worktree "))
1342        .map(PathBuf::from)
1343        .filter(|path| is_fallow_audit_worktree_path(path))
1344        .collect()
1345}
1346
1347pub fn is_fallow_audit_worktree_path(path: &Path) -> bool {
1348    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1349        return false;
1350    };
1351    name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
1352}
1353
1354pub fn is_reusable_audit_worktree_path(path: &Path) -> bool {
1355    path.file_name()
1356        .and_then(|name| name.to_str())
1357        .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
1358}
1359
1360fn path_is_inside_temp_dir(path: &Path) -> bool {
1361    let temp = std::env::temp_dir();
1362    let simple_path = dunce::simplified(path);
1363    let simple_temp = dunce::simplified(&temp);
1364    if simple_path.starts_with(simple_temp) {
1365        return true;
1366    }
1367    let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
1368        return false;
1369    };
1370    let simple_canonical_temp = dunce::simplified(&canonical_temp);
1371    simple_path.starts_with(simple_canonical_temp)
1372        || std::fs::canonicalize(path).is_ok_and(|canonical_path| {
1373            dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
1374        })
1375}
1376
1377fn audit_worktree_process_is_alive(path: &Path) -> bool {
1378    let Some(pid) = path
1379        .file_name()
1380        .and_then(|name| name.to_str())
1381        .and_then(audit_worktree_pid)
1382    else {
1383        return false;
1384    };
1385    process_is_alive(pid)
1386}
1387
1388pub fn audit_worktree_pid(name: &str) -> Option<u32> {
1389    name.strip_prefix("fallow-audit-base-")?
1390        .split('-')
1391        .next()?
1392        .parse()
1393        .ok()
1394}
1395
1396#[cfg(unix)]
1397pub fn process_is_alive(pid: u32) -> bool {
1398    Command::new("kill")
1399        .args(["-0", &pid.to_string()])
1400        .output()
1401        .is_ok_and(|output| output.status.success())
1402}
1403
1404#[cfg(windows)]
1405pub fn process_is_alive(pid: u32) -> bool {
1406    windows_process::is_alive(pid)
1407}
1408
1409#[cfg(not(any(unix, windows)))]
1410pub fn process_is_alive(_pid: u32) -> bool {
1411    true
1412}
1413
1414#[cfg(windows)]
1415#[allow(
1416    unsafe_code,
1417    reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
1418)]
1419mod windows_process {
1420    use windows_sys::Win32::Foundation::{
1421        CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
1422        WAIT_OBJECT_0,
1423    };
1424    use windows_sys::Win32::System::Threading::{
1425        OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
1426    };
1427
1428    /// RAII wrapper that calls `CloseHandle` on drop, mirroring `std::mem::drop`
1429    /// semantics for kernel handles. Used so every exit path through
1430    /// `is_alive` releases the handle without manual cleanup.
1431    struct ProcessHandle(HANDLE);
1432
1433    impl Drop for ProcessHandle {
1434        fn drop(&mut self) {
1435            // SAFETY: `self.0` is a non-null handle obtained from a successful
1436            // `OpenProcess` call. We have unique ownership (the value is only
1437            // ever created inside `is_alive`), so this is the sole consumer.
1438            unsafe {
1439                CloseHandle(self.0);
1440            }
1441        }
1442    }
1443
1444    /// Cross-platform PID liveness check for Windows.
1445    ///
1446    /// Mirrors `kill -0 $pid` semantics: returns `true` when the process is
1447    /// running OR when we cannot prove it dead (e.g., `ERROR_ACCESS_DENIED` on
1448    /// processes owned by another session). Returns `false` only when the PID
1449    /// definitively does not exist (`ERROR_INVALID_PARAMETER`) or the wait
1450    /// reports the process has exited.
1451    pub fn is_alive(pid: u32) -> bool {
1452        // SAFETY: `OpenProcess` accepts any `u32` PID; it either returns a
1453        // non-null handle we own, or null on failure with `GetLastError`
1454        // describing why. No memory is borrowed across the FFI boundary.
1455        let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
1456        if raw.is_null() {
1457            // SAFETY: `GetLastError` reads thread-local storage set by the
1458            // failing `OpenProcess` call. It has no preconditions.
1459            let err = unsafe { GetLastError() };
1460            #[expect(
1461                clippy::match_same_arms,
1462                reason = "named arm documents the cross-session case"
1463            )]
1464            return match err {
1465                ERROR_INVALID_PARAMETER => false,
1466                ERROR_ACCESS_DENIED => true,
1467                _ => true,
1468            };
1469        }
1470        let handle = ProcessHandle(raw);
1471        // SAFETY: `handle.0` is non-null (checked above) and owned by the
1472        // `ProcessHandle` RAII wrapper.
1473        let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
1474        wait_result != WAIT_OBJECT_0
1475    }
1476}
1477
1478impl Drop for BaseWorktree {
1479    fn drop(&mut self) {
1480        if self.persistent {
1481            return;
1482        }
1483        // The non-reusable worktree was deregistered right after creation, so
1484        // cleanup is a plain directory removal: no `git` subprocess runs, and
1485        // a SIGKILL before this Drop can never leave an admin entry behind.
1486        let _ = std::fs::remove_dir_all(&self.path);
1487    }
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492    use super::*;
1493
1494    /// Many threads minting a non-reusable worktree path at the same instant
1495    /// must each get a distinct path. Before the monotonic counter, concurrent
1496    /// callers read the same non-monotonic `nanos` and collided, so two parallel
1497    /// `audit` runs in one process raced on `git worktree add` and one aborted
1498    /// with a generic error (a flaky exit 2 under parallel unit tests).
1499    #[test]
1500    fn non_reusable_worktree_paths_are_unique_under_concurrency() {
1501        const N: usize = 64;
1502        let barrier = std::sync::Barrier::new(N);
1503        let paths = std::sync::Mutex::new(Vec::with_capacity(N));
1504        std::thread::scope(|s| {
1505            for _ in 0..N {
1506                let barrier = &barrier;
1507                let paths = &paths;
1508                s.spawn(move || {
1509                    barrier.wait();
1510                    let path = non_reusable_worktree_path().expect("path should build");
1511                    paths.lock().unwrap().push(path);
1512                });
1513            }
1514        });
1515        let mut paths = paths.into_inner().unwrap();
1516        assert_eq!(paths.len(), N);
1517        paths.sort();
1518        paths.dedup();
1519        assert_eq!(paths.len(), N, "non-reusable worktree paths collided");
1520    }
1521
1522    /// The pid stays the first segment so orphan-sweep parsing keeps working.
1523    #[test]
1524    fn non_reusable_worktree_path_pid_is_parseable() {
1525        let path = non_reusable_worktree_path().expect("path should build");
1526        let name = path.file_name().unwrap().to_str().unwrap();
1527        assert!(is_fallow_audit_worktree_path(&path));
1528        assert!(!is_reusable_audit_worktree_path(&path));
1529        assert_eq!(audit_worktree_pid(name), Some(std::process::id()));
1530    }
1531
1532    #[cfg(unix)]
1533    #[test]
1534    fn cache_sidecar_open_does_not_follow_symlinks() {
1535        let temp = tempfile::TempDir::new().expect("temp dir should be created");
1536        let victim = temp.path().join("victim");
1537        let sidecar = temp.path().join("cache.lock");
1538        std::fs::write(&victim, "unchanged\n").expect("victim should be written");
1539        std::os::unix::fs::symlink(&victim, &sidecar).expect("sidecar symlink should be created");
1540
1541        assert!(open_or_create_owned_sidecar(&sidecar).is_err());
1542        assert_eq!(
1543            std::fs::read_to_string(victim).expect("victim should remain readable"),
1544            "unchanged\n",
1545        );
1546    }
1547}