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