1use std::io::{Read, Write};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{Duration, SystemTime};
6
7use fallow_engine::changed_files::clear_ambient_git_env;
8pub use fallow_engine::repo_refs::materialize_base_dependency_context;
9use rustc_hash::{FxHashMap, FxHashSet};
10use xxhash_rust::xxh3::xxh3_64;
11
12use crate::report::plural;
13
14pub struct BaseWorktree {
15 path: PathBuf,
16 persistent: bool,
17 _reusable_lock: Option<ReusableWorktreeLock>,
18}
19
20impl BaseWorktree {
21 pub fn create(
24 repo_root: &Path,
25 base_ref: &str,
26 base_sha: Option<&str>,
27 workspace_patterns: &[String],
28 ) -> Option<Self> {
29 sweep_orphan_audit_worktrees(repo_root);
30 if let Some(base_sha) = base_sha
31 && let Some(worktree) = Self::reuse_or_create(repo_root, base_sha, workspace_patterns)
32 {
33 return Some(worktree);
34 }
35 let path = non_reusable_worktree_path()?;
36 let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
37 if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
38 repo_root,
39 guard.path(),
40 base_ref,
41 ) {
42 tracing::debug!(
43 base_ref,
44 error = %error,
45 "could not materialize non-reusable audit base worktree",
46 );
47 return None;
48 }
49 if let Err(error) = unregister_worktree(repo_root, guard.path()) {
54 tracing::debug!(
55 path = %guard.path().display(),
56 error = %error,
57 "could not deregister non-reusable audit base worktree",
58 );
59 return None;
60 }
61 guard.defuse();
62 drop(guard);
63 let worktree = Self {
64 path,
65 persistent: false,
66 _reusable_lock: None,
67 };
68 materialize_base_dependency_context(repo_root, worktree.path(), workspace_patterns);
69 Some(worktree)
70 }
71
72 pub fn reuse_or_create(
73 repo_root: &Path,
74 base_sha: &str,
75 workspace_patterns: &[String],
76 ) -> Option<Self> {
77 let path = reusable_audit_worktree_path(repo_root);
78 let reusable_lock =
79 ReusableWorktreeLock::try_acquire(&path, "falling back to non-reusable worktree")?;
80
81 if reusable_audit_worktree_is_ready(&path, base_sha)
82 || try_migrate_registered_current_cache(repo_root, &path, base_sha)
83 {
84 let worktree = Self {
85 path,
86 persistent: true,
87 _reusable_lock: Some(reusable_lock),
88 };
89 materialize_base_dependency_context(repo_root, worktree.path(), workspace_patterns);
90 record_last_used(worktree.path(), repo_root);
91 return Some(worktree);
92 }
93
94 if let Err(error) = remove_file_if_exists(&reusable_worktree_sha_path(&path)) {
95 tracing::debug!(
96 path = %path.display(),
97 error = %error,
98 "could not clear reusable audit worktree readiness before rebuild",
99 );
100 return None;
101 }
102 if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
103 tracing::debug!(
104 path = %path.display(),
105 error = %error,
106 "could not remove stale reusable audit worktree before rebuild",
107 );
108 return None;
109 }
110 let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
111 if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
112 repo_root,
113 guard.path(),
114 base_sha,
115 ) {
116 tracing::debug!(
117 base_sha,
118 error = %error,
119 "could not materialize reusable audit base worktree",
120 );
121 return None;
122 }
123 if let Err(error) = unregister_worktree_checked(repo_root, guard.path()) {
128 tracing::debug!(
129 path = %guard.path().display(),
130 error = %error,
131 "could not deregister reusable audit base worktree",
132 );
133 return None;
134 }
135 guard.defuse();
136 drop(guard);
137 let readiness_published = write_reusable_sha(&path, base_sha).is_ok();
138
139 let worktree = Self {
140 path,
141 persistent: true,
142 _reusable_lock: Some(reusable_lock),
143 };
144 materialize_base_dependency_context(repo_root, worktree.path(), workspace_patterns);
145 if readiness_published {
146 record_last_used(worktree.path(), repo_root);
147 }
148 Some(worktree)
149 }
150
151 pub fn path(&self) -> &Path {
152 &self.path
153 }
154}
155
156fn non_reusable_worktree_path() -> Option<PathBuf> {
166 static SEQ: AtomicU64 = AtomicU64::new(0);
167 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
168 let nanos = SystemTime::now()
169 .duration_since(SystemTime::UNIX_EPOCH)
170 .ok()?
171 .as_nanos();
172 Some(std::env::temp_dir().join(format!(
173 "fallow-audit-base-{}-{nanos}-{seq}",
174 std::process::id()
175 )))
176}
177
178pub struct WorktreeCleanupGuard<'a> {
190 repo_root: PathBuf,
191 path: &'a Path,
192 armed: bool,
193}
194
195impl<'a> WorktreeCleanupGuard<'a> {
196 pub fn new(repo_root: &Path, path: &'a Path) -> Self {
197 Self {
198 repo_root: repo_root.to_path_buf(),
199 path,
200 armed: true,
201 }
202 }
203
204 pub fn path(&self) -> &Path {
205 self.path
206 }
207
208 pub fn defuse(&mut self) {
211 self.armed = false;
212 }
213}
214
215impl Drop for WorktreeCleanupGuard<'_> {
216 fn drop(&mut self) {
217 if self.armed {
218 remove_audit_worktree(&self.repo_root, self.path);
219 let _ = std::fs::remove_dir_all(self.path);
220 }
221 }
222}
223
224pub struct ReusableWorktreeLock {
230 file: std::fs::File,
231}
232
233impl ReusableWorktreeLock {
234 pub fn try_acquire(reusable_path: &Path, context: &'static str) -> Option<Self> {
238 let lock_path = reusable_worktree_lock_path(reusable_path);
239 let file = open_or_create_owned_sidecar(&lock_path).ok()?;
240 match file.try_lock() {
241 Ok(()) => Some(Self { file }),
242 Err(std::fs::TryLockError::WouldBlock) => {
243 tracing::debug!(
244 path = %lock_path.display(),
245 context,
246 "reusable audit worktree lock contended",
247 );
248 None
249 }
250 Err(std::fs::TryLockError::Error(err)) => {
251 tracing::debug!(
252 path = %lock_path.display(),
253 error = %err,
254 context,
255 "could not acquire reusable audit worktree lock",
256 );
257 None
258 }
259 }
260 }
261}
262
263impl Drop for ReusableWorktreeLock {
264 fn drop(&mut self) {
265 let _ = self.file.unlock();
266 }
267}
268
269pub fn reusable_worktree_lock_path(reusable_path: &Path) -> PathBuf {
270 sidecar_path(reusable_path, REUSABLE_LOCK_SUFFIX)
271}
272
273fn sidecar_path(reusable_path: &Path, suffix: &str) -> PathBuf {
277 let mut name = reusable_path
278 .file_name()
279 .map(std::ffi::OsString::from)
280 .unwrap_or_default();
281 name.push(suffix);
282 reusable_path
283 .parent()
284 .map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
285}
286
287pub fn reusable_worktree_sha_path(reusable_path: &Path) -> PathBuf {
291 sidecar_path(reusable_path, REUSABLE_SHA_SUFFIX)
292}
293
294fn write_reusable_sha(reusable_path: &Path, base_sha: &str) -> std::io::Result<()> {
297 static SEQ: AtomicU64 = AtomicU64::new(0);
298
299 let sha_path = reusable_worktree_sha_path(reusable_path);
300 let sequence = SEQ.fetch_add(1, Ordering::Relaxed);
301 let temp_path = sidecar_path(
302 reusable_path,
303 &format!(
304 "{REUSABLE_SHA_SUFFIX}.tmp-{}-{sequence}",
305 std::process::id()
306 ),
307 );
308 let result = (|| {
309 let mut options = std::fs::OpenOptions::new();
310 options.create_new(true).write(true);
311 #[cfg(unix)]
312 {
313 use std::os::unix::fs::OpenOptionsExt as _;
314 options.mode(0o600);
315 }
316 let mut file = options.open(&temp_path)?;
317 file.write_all(format!("{base_sha}\n").as_bytes())?;
318 file.sync_all()?;
319 std::fs::rename(&temp_path, &sha_path)
320 })();
321 if let Err(err) = &result {
322 let _ = std::fs::remove_file(&temp_path);
323 tracing::debug!(
324 path = %sha_path.display(),
325 error = %err,
326 "failed to write reusable audit worktree .sha sidecar; next run will rebuild",
327 );
328 }
329 result
330}
331
332const DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS: u32 = 30;
334
335pub const SECONDS_PER_DAY: u64 = 86_400;
336
337const AUDIT_CACHE_MAX_AGE_ENV: &str = "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS";
339
340const REUSABLE_LAST_USED_SUFFIX: &str = ".last-used";
342
343const REUSABLE_SHA_SUFFIX: &str = ".sha";
345
346const REUSABLE_LOCK_SUFFIX: &str = ".lock";
348
349const UNREGISTERED_GITDIR_STUB: &str = "gitdir: fallow-audit-unregistered\n";
360
361pub fn reusable_worktree_last_used_path(reusable_path: &Path) -> PathBuf {
366 sidecar_path(reusable_path, REUSABLE_LAST_USED_SUFFIX)
367}
368
369pub fn touch_last_used(reusable_path: &Path) {
377 stamp_last_used(reusable_path, None);
378}
379
380pub fn record_last_used(reusable_path: &Path, owner_root: &Path) {
387 stamp_last_used(reusable_path, Some(owner_root));
388}
389
390fn stamp_last_used(reusable_path: &Path, owner_root: Option<&Path>) {
391 let last_used = reusable_worktree_last_used_path(reusable_path);
392 let result = open_or_create_owned_sidecar(&last_used).and_then(|mut file| {
393 if let Some(owner_root) = owner_root {
394 file.set_len(0)?;
395 file.write_all(format!("{}\n", owner_root.display()).as_bytes())?;
396 }
397 file.set_modified(SystemTime::now())
398 });
399 if let Err(err) = result {
400 tracing::warn!(
401 path = %last_used.display(),
402 error = %err,
403 "failed to touch reusable audit worktree sidecar; staleness signal may not update",
404 );
405 }
406}
407
408fn read_last_used_owner(reusable_path: &Path) -> Option<PathBuf> {
413 const MAX_OWNER_SIDECAR_BYTES: u64 = 4096;
414
415 let sidecar = reusable_worktree_last_used_path(reusable_path);
416 let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
417 if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_OWNER_SIDECAR_BYTES {
418 return None;
419 }
420 let mut contents = String::new();
421 std::fs::File::open(sidecar)
422 .ok()?
423 .take(MAX_OWNER_SIDECAR_BYTES)
424 .read_to_string(&mut contents)
425 .ok()?;
426 let owner = contents.trim();
427 if owner.is_empty() {
428 return None;
429 }
430 Some(PathBuf::from(owner))
431}
432
433fn open_or_create_owned_sidecar(path: &Path) -> std::io::Result<std::fs::File> {
434 match std::fs::symlink_metadata(path) {
435 Ok(metadata) if sidecar_metadata_is_trusted(&metadata) => {
436 std::fs::OpenOptions::new().write(true).open(path)
437 }
438 Ok(_) => Err(std::io::Error::new(
439 std::io::ErrorKind::PermissionDenied,
440 "refusing to open an untrusted audit cache sidecar",
441 )),
442 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
443 let mut options = std::fs::OpenOptions::new();
444 options.create_new(true).write(true);
445 #[cfg(unix)]
446 {
447 use std::os::unix::fs::OpenOptionsExt as _;
448 options.mode(0o600);
449 }
450 options.open(path)
451 }
452 Err(error) => Err(error),
453 }
454}
455
456#[cfg(unix)]
457fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
458 use std::os::unix::fs::MetadataExt as _;
459
460 metadata_is_regular_file(metadata) && metadata.uid() == rustix::process::geteuid().as_raw()
461}
462
463#[cfg(not(unix))]
464fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
465 metadata_is_regular_file(metadata)
466}
467
468#[expect(
469 clippy::filetype_is_file,
470 reason = "security-sensitive sidecars and gitfiles must be regular files, not arbitrary non-directories"
471)]
472fn metadata_is_regular_file(metadata: &std::fs::Metadata) -> bool {
473 metadata.file_type().is_file()
474}
475
476#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub enum CacheMaxAgeSource {
479 Flag,
480 Env,
481 Config,
482 Default,
483}
484
485impl CacheMaxAgeSource {
486 #[must_use]
487 pub const fn as_str(self) -> &'static str {
488 match self {
489 Self::Flag => "flag",
490 Self::Env => "env",
491 Self::Config => "config",
492 Self::Default => "default",
493 }
494 }
495}
496
497#[derive(Debug, Clone, Copy)]
500pub struct ResolvedCacheMaxAge {
501 pub max_age: Option<Duration>,
502 pub days: u32,
503 pub source: CacheMaxAgeSource,
504}
505
506pub fn resolve_cache_max_age_with_options(
513 root: &Path,
514 config_path: Option<&PathBuf>,
515 allow_remote_extends: bool,
516) -> Option<Duration> {
517 resolve_cache_max_age_with_source(root, config_path, allow_remote_extends, None).max_age
518}
519
520pub fn resolve_cache_max_age_with_source(
524 root: &Path,
525 config_path: Option<&PathBuf>,
526 allow_remote_extends: bool,
527 flag_days: Option<u32>,
528) -> ResolvedCacheMaxAge {
529 if let Some(days) = flag_days {
530 return ResolvedCacheMaxAge {
531 max_age: days_to_duration(days),
532 days,
533 source: CacheMaxAgeSource::Flag,
534 };
535 }
536 if let Ok(raw) = std::env::var(AUDIT_CACHE_MAX_AGE_ENV) {
537 if let Ok(days) = raw.trim().parse::<u32>() {
538 return ResolvedCacheMaxAge {
539 max_age: days_to_duration(days),
540 days,
541 source: CacheMaxAgeSource::Env,
542 };
543 }
544 tracing::warn!(
545 value = %raw,
546 "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS is not a valid u32; falling back to config/default",
547 );
548 }
549 if let Some(days) = load_audit_config(root, config_path, allow_remote_extends)
550 .and_then(|c| c.cache_max_age_days)
551 {
552 return ResolvedCacheMaxAge {
553 max_age: days_to_duration(days),
554 days,
555 source: CacheMaxAgeSource::Config,
556 };
557 }
558 ResolvedCacheMaxAge {
559 max_age: days_to_duration(DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS),
560 days: DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS,
561 source: CacheMaxAgeSource::Default,
562 }
563}
564
565pub fn days_to_duration(days: u32) -> Option<Duration> {
566 if days == 0 {
567 return None;
568 }
569 Some(Duration::from_secs(u64::from(days) * SECONDS_PER_DAY))
570}
571
572fn load_audit_config(
576 root: &Path,
577 config_path: Option<&PathBuf>,
578 allow_remote_extends: bool,
579) -> Option<fallow_config::AuditConfig> {
580 let options = fallow_config::ConfigLoadOptions {
581 allow_remote_extends,
582 };
583 if let Some(path) = config_path {
584 return fallow_config::FallowConfig::load_with_options(path, options)
585 .ok()
586 .map(|config| config.audit);
587 }
588 fallow_config::FallowConfig::find_and_load_with_options(root, options)
589 .ok()
590 .flatten()
591 .map(|(config, _path)| config.audit)
592}
593
594pub fn sweep_old_reusable_caches(repo_root: &Path, max_age: Option<Duration>, quiet: bool) {
629 sweep_old_reusable_caches_in(repo_root, max_age, quiet, &std::env::temp_dir());
630}
631
632pub fn sweep_old_reusable_caches_in(
638 repo_root: &Path,
639 max_age: Option<Duration>,
640 quiet: bool,
641 scan_root: &Path,
642) {
643 let report = sweep_reusable_caches_with_report(
644 repo_root,
645 max_age,
646 scan_root,
647 SweepMode::Apply,
648 SweepSizes::Skip,
649 );
650 log_sweep_entries(&report, SweepMode::Apply, max_age);
651 let removed = report.removed;
652 if removed == 0 {
653 return;
654 }
655 tracing::info!(
656 count = removed,
657 "reclaimed stale audit base-snapshot caches",
658 );
659 if !quiet {
660 let s = plural(removed as usize);
661 let _ = writeln!(
662 std::io::stderr(),
663 "fallow: reclaimed {removed} stale base-snapshot cache{s}",
664 );
665 }
666}
667
668#[derive(Debug, Clone, Copy, PartialEq, Eq)]
670pub enum SweepMode {
671 Apply,
673 DryRun,
677}
678
679impl SweepMode {
680 #[must_use]
681 pub const fn as_str(self) -> &'static str {
682 match self {
683 Self::Apply => "apply",
684 Self::DryRun => "dry-run",
685 }
686 }
687}
688
689#[derive(Debug, Clone, Copy, PartialEq, Eq)]
693pub enum SweepSizes {
694 Skip,
695 Measure,
696}
697
698#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700pub enum SweepPass {
701 Owned,
703 Legacy,
705 Foreign,
707 Snapshot,
710}
711
712impl SweepPass {
713 #[must_use]
714 pub const fn as_str(self) -> &'static str {
715 match self {
716 Self::Owned => "owned",
717 Self::Legacy => "legacy",
718 Self::Foreign => "foreign",
719 Self::Snapshot => "snapshot",
720 }
721 }
722}
723
724#[derive(Debug, Clone, Copy, PartialEq, Eq)]
727pub enum SweepDecision {
728 Removed,
729 Kept,
730 Skipped,
731 Failed,
732}
733
734impl SweepDecision {
735 #[must_use]
736 pub const fn as_str(self) -> &'static str {
737 match self {
738 Self::Removed => "removed",
739 Self::Kept => "kept",
740 Self::Skipped => "skipped",
741 Self::Failed => "failed",
742 }
743 }
744}
745
746#[derive(Debug, Clone, Copy, PartialEq, Eq)]
748pub enum SweepDisposition {
749 ReclaimedOrphan,
751 ReclaimedAged,
753 ReclaimedOwnerMissing,
758 ReclaimedLegacyRegistered,
762 ReclaimedOverCapacity,
766 KeptLegacyDeregistered,
771 KeptFresh,
773 KeptOwnerLive,
776 KeptOwnerUnverifiable(std::io::ErrorKind),
779 KeptAgeGcDisabled,
782 KeptGraceSeeded,
785 KeptLockOnly,
788 KeptRecreated,
791 KeptNotOwned,
794 SkippedLocked,
796 RemoveFailed,
798}
799
800impl SweepDisposition {
801 #[must_use]
802 pub const fn decision(self) -> SweepDecision {
803 match self {
804 Self::ReclaimedOrphan
805 | Self::ReclaimedAged
806 | Self::ReclaimedOwnerMissing
807 | Self::ReclaimedLegacyRegistered
808 | Self::ReclaimedOverCapacity => SweepDecision::Removed,
809 Self::KeptLegacyDeregistered
810 | Self::KeptFresh
811 | Self::KeptOwnerLive
812 | Self::KeptOwnerUnverifiable(_)
813 | Self::KeptAgeGcDisabled
814 | Self::KeptGraceSeeded
815 | Self::KeptLockOnly
816 | Self::KeptRecreated
817 | Self::KeptNotOwned => SweepDecision::Kept,
818 Self::SkippedLocked => SweepDecision::Skipped,
819 Self::RemoveFailed => SweepDecision::Failed,
820 }
821 }
822
823 #[must_use]
826 pub const fn reason(self) -> &'static str {
827 match self {
828 Self::ReclaimedOrphan => "orphaned-sidecars",
829 Self::ReclaimedAged => "aged-out",
830 Self::ReclaimedOwnerMissing => "owner-missing",
831 Self::ReclaimedLegacyRegistered => "legacy-registered",
832 Self::ReclaimedOverCapacity => "over-capacity",
833 Self::KeptLegacyDeregistered => "legacy-deregistered",
834 Self::KeptFresh => "fresh",
835 Self::KeptOwnerLive => "owner-live",
836 Self::KeptOwnerUnverifiable(_) => "owner-unverifiable",
837 Self::KeptAgeGcDisabled => "age-gc-disabled",
838 Self::KeptGraceSeeded => "grace-seeded",
839 Self::KeptLockOnly => "lock-only",
840 Self::KeptRecreated => "recreated",
841 Self::KeptNotOwned => "not-owned",
842 Self::SkippedLocked => "lock-contention",
843 Self::RemoveFailed => "remove-failed",
844 }
845 }
846
847 const fn counts_toward_summary(self) -> bool {
850 matches!(
851 self,
852 Self::ReclaimedOrphan | Self::ReclaimedAged | Self::ReclaimedOwnerMissing
853 )
854 }
855}
856
857#[derive(Debug, Clone)]
859pub struct SweepEntry {
860 pub path: PathBuf,
861 pub pass: SweepPass,
862 pub disposition: SweepDisposition,
863 pub age_days: Option<u64>,
865 pub owner_root: Option<PathBuf>,
867 pub size_bytes: Option<u64>,
870}
871
872#[derive(Debug, Default)]
876pub struct SweepReport {
877 pub entries: Vec<SweepEntry>,
878 pub removed: u32,
879}
880
881impl SweepReport {
882 fn push(&mut self, entry: SweepEntry) {
883 if entry.disposition.counts_toward_summary() {
884 self.removed += 1;
885 }
886 self.entries.push(entry);
887 }
888}
889
890const GC_LOCK_CONTEXT: &str = "gc sweep skips the entry";
891
892pub fn log_sweep_entries(report: &SweepReport, mode: SweepMode, max_age: Option<Duration>) {
896 let threshold_days = max_age.map(|age| age.as_secs() / SECONDS_PER_DAY);
897 for entry in &report.entries {
898 let owner_probe_error = match entry.disposition {
899 SweepDisposition::KeptOwnerUnverifiable(kind) => Some(kind),
900 _ => None,
901 };
902 tracing::debug!(
903 path = %entry.path.display(),
904 pass = entry.pass.as_str(),
905 mode = mode.as_str(),
906 decision = entry.disposition.decision().as_str(),
907 reason = entry.disposition.reason(),
908 age_days = entry.age_days,
909 threshold_days,
910 owner_root = entry
911 .owner_root
912 .as_ref()
913 .map(|owner| tracing::field::display(owner.display())),
914 owner_probe_error = owner_probe_error.map(tracing::field::debug),
915 "audit cache sweep considered entry",
916 );
917 }
918}
919
920pub fn sweep_reusable_caches_with_report(
926 repo_root: &Path,
927 max_age: Option<Duration>,
928 scan_root: &Path,
929 mode: SweepMode,
930 sizes: SweepSizes,
931) -> SweepReport {
932 let now = SystemTime::now();
933 let mut report = SweepReport::default();
934
935 legacy_registered_pass(repo_root, mode, sizes, now, &mut report);
941
942 let owned_path = reusable_audit_worktree_path(repo_root);
946 let mut paths = vec![owned_path.clone()];
947 paths.extend(scan_legacy_reusable_cache_paths(repo_root, scan_root));
948 paths.sort();
949 paths.dedup();
950
951 let scoped: FxHashSet<&PathBuf> = paths.iter().collect();
958 let foreign: Vec<PathBuf> = scan_all_reusable_cache_paths(scan_root)
959 .into_iter()
960 .filter(|path| !scoped.contains(path))
961 .collect();
962 drop(scoped);
963
964 let mut size_map = measure_entry_sizes(sizes, paths.iter().chain(foreign.iter()));
967
968 for path in &paths {
969 if !cache_entry_has_presence(path) {
975 continue;
976 }
977 let pass = if *path == owned_path {
978 SweepPass::Owned
979 } else {
980 SweepPass::Legacy
981 };
982 let (age_days, owner_root) = entry_probe_metadata(path, now);
983 let disposition = match mode {
984 SweepMode::Apply => reclaim_reusable_cache_entry(repo_root, path, max_age, now),
985 SweepMode::DryRun => classify_entry_dry_run(path, max_age, now, OwnerGate::Off),
986 };
987 report.push(SweepEntry {
988 path: path.clone(),
989 pass,
990 disposition,
991 age_days,
992 owner_root,
993 size_bytes: size_map.remove(path).flatten(),
994 });
995 }
996 for path in &foreign {
997 let (age_days, owner_root) = entry_probe_metadata(path, now);
998 let disposition = match mode {
999 SweepMode::Apply => reclaim_foreign_cache_entry(repo_root, path, max_age, now),
1000 SweepMode::DryRun => classify_entry_dry_run(path, max_age, now, OwnerGate::On),
1001 };
1002 report.push(SweepEntry {
1003 path: path.clone(),
1004 pass: SweepPass::Foreign,
1005 disposition,
1006 age_days,
1007 owner_root,
1008 size_bytes: size_map.remove(path).flatten(),
1009 });
1010 }
1011 report
1012}
1013
1014fn entry_probe_metadata(path: &Path, now: SystemTime) -> (Option<u64>, Option<PathBuf>) {
1017 let age_days = last_used_mtime(path)
1018 .and_then(|mtime| now.duration_since(mtime).ok())
1019 .map(|age| age.as_secs() / SECONDS_PER_DAY);
1020 (age_days, read_last_used_owner(path))
1021}
1022
1023fn legacy_registered_pass(
1033 repo_root: &Path,
1034 mode: SweepMode,
1035 sizes: SweepSizes,
1036 now: SystemTime,
1037 report: &mut SweepReport,
1038) {
1039 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1040 return;
1041 };
1042 let candidates: Vec<PathBuf> = worktrees
1043 .into_iter()
1044 .filter(|path| is_reusable_audit_worktree_path(path))
1045 .collect();
1046 let mut size_map = measure_entry_sizes(sizes, candidates.iter());
1047 let owned_path = reusable_audit_worktree_path(repo_root);
1048 let mut deregistered = false;
1049 for path in candidates {
1050 let pass = if paths_equal(&path, &owned_path) {
1051 SweepPass::Owned
1052 } else {
1053 SweepPass::Legacy
1054 };
1055 let (age_days, owner_root) = entry_probe_metadata(&path, now);
1056 let size_bytes = size_map.remove(&path).flatten();
1057 let is_current_path = pass == SweepPass::Owned;
1058 let disposition = match mode {
1059 SweepMode::DryRun => {
1060 if !audit_worktree_is_registered(repo_root, &path) {
1061 continue;
1062 }
1063 if is_current_path {
1064 SweepDisposition::KeptLegacyDeregistered
1065 } else {
1066 SweepDisposition::ReclaimedLegacyRegistered
1067 }
1068 }
1069 SweepMode::Apply => {
1070 let Some(_lock) = ReusableWorktreeLock::try_acquire(
1071 &path,
1072 "legacy deregistration skips the entry",
1073 ) else {
1074 report.push(SweepEntry {
1075 path,
1076 pass,
1077 disposition: SweepDisposition::SkippedLocked,
1078 age_days,
1079 owner_root,
1080 size_bytes,
1081 });
1082 continue;
1083 };
1084 if !audit_worktree_is_registered(repo_root, &path) {
1085 continue;
1086 }
1087 let head = is_current_path
1088 .then(|| legacy_reusable_sha(&path))
1089 .flatten();
1090 if unregister_worktree_checked(repo_root, &path).is_err() {
1091 report.push(SweepEntry {
1092 path,
1093 pass,
1094 disposition: SweepDisposition::RemoveFailed,
1095 age_days,
1096 owner_root,
1097 size_bytes,
1098 });
1099 continue;
1100 }
1101 let disposition = if is_current_path {
1102 if let Some(head) = head {
1103 let _ = write_reusable_sha(&path, &head);
1104 }
1105 SweepDisposition::KeptLegacyDeregistered
1106 } else if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
1107 tracing::warn!(
1108 path = %path.display(),
1109 error = %error,
1110 "failed to remove released SHA-keyed audit cache",
1111 );
1112 SweepDisposition::RemoveFailed
1113 } else {
1114 SweepDisposition::ReclaimedLegacyRegistered
1115 };
1116 deregistered = true;
1117 disposition
1118 }
1119 };
1120 report.push(SweepEntry {
1121 path,
1122 pass,
1123 disposition,
1124 age_days,
1125 owner_root,
1126 size_bytes,
1127 });
1128 }
1129 if deregistered {
1130 let mut command = Command::new("git");
1131 command
1132 .args(["worktree", "prune", "--expire=now"])
1133 .current_dir(repo_root);
1134 clear_ambient_git_env(&mut command);
1135 let _ = command.output();
1136 }
1137}
1138
1139fn legacy_reusable_sha(path: &Path) -> Option<String> {
1143 if reusable_worktree_sha_path(path).exists()
1144 || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
1145 {
1146 return None;
1147 }
1148 git_rev_parse(path, "HEAD")
1149}
1150
1151fn scan_legacy_reusable_cache_paths(repo_root: &Path, scan_root: &Path) -> Vec<PathBuf> {
1156 let Some(prefix) = legacy_reusable_cache_repo_prefix(repo_root) else {
1157 return Vec::new();
1158 };
1159 scan_cache_paths_with_hex_suffix(&prefix, scan_root)
1160}
1161
1162fn scan_root_owned_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
1163 let Some(prefix) = root_owned_cache_repo_prefix(repo_root) else {
1164 return Vec::new();
1165 };
1166 scan_cache_paths_with_hex_suffix(&prefix, &std::env::temp_dir())
1167}
1168
1169fn scan_all_reusable_cache_paths(scan_root: &Path) -> Vec<PathBuf> {
1175 const GLOBAL_CACHE_PREFIX: &str = "fallow-audit-base-cache-";
1176
1177 let Ok(entries) = std::fs::read_dir(scan_root) else {
1178 return Vec::new();
1179 };
1180 let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
1181 let mut paths = Vec::new();
1182 for entry in entries.flatten() {
1183 let name = entry.file_name();
1184 let Some(name) = name.to_str() else {
1185 continue;
1186 };
1187 let cache_name = strip_cache_sidecar_suffix(name);
1188 let Some(hash_suffix) = cache_name.strip_prefix(GLOBAL_CACHE_PREFIX) else {
1189 continue;
1190 };
1191 if !cache_hash_suffix_is_valid(hash_suffix) {
1192 continue;
1193 }
1194 let path = scan_root.join(cache_name);
1195 if seen.insert(path.clone()) {
1196 paths.push(path);
1197 }
1198 }
1199 paths
1200}
1201
1202fn cache_hash_suffix_is_valid(suffix: &str) -> bool {
1203 fn is_hex16(part: &str) -> bool {
1204 part.len() == 16 && part.bytes().all(|byte| byte.is_ascii_hexdigit())
1205 }
1206 if let Some((repo, root)) = suffix.split_once("-root-") {
1207 return is_hex16(repo) && is_hex16(root);
1208 }
1209 suffix
1210 .split_once('-')
1211 .is_some_and(|(repo, sha)| is_hex16(repo) && is_hex16(sha))
1212}
1213
1214fn scan_cache_paths_with_hex_suffix(prefix: &str, scan_root: &Path) -> Vec<PathBuf> {
1215 let Ok(entries) = std::fs::read_dir(scan_root) else {
1216 return Vec::new();
1217 };
1218 let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
1219 let mut paths = Vec::new();
1220 for entry in entries.flatten() {
1221 let name = entry.file_name();
1222 let Some(name) = name.to_str() else {
1223 continue;
1224 };
1225 let cache_name = strip_cache_sidecar_suffix(name);
1226 let Some(hash_suffix) = cache_name.strip_prefix(prefix) else {
1227 continue;
1228 };
1229 if hash_suffix.len() != 16 || !hash_suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1230 continue;
1231 }
1232 let path = scan_root.join(cache_name);
1233 if seen.insert(path.clone()) {
1234 paths.push(path);
1235 }
1236 }
1237 paths
1238}
1239
1240fn strip_cache_sidecar_suffix(name: &str) -> &str {
1244 for suffix in [
1245 REUSABLE_LAST_USED_SUFFIX,
1246 REUSABLE_SHA_SUFFIX,
1247 REUSABLE_LOCK_SUFFIX,
1248 ] {
1249 if let Some(stripped) = name.strip_suffix(suffix) {
1250 return stripped;
1251 }
1252 }
1253 name
1254}
1255
1256fn reclaim_reusable_cache_entry(
1258 repo_root: &Path,
1259 path: &Path,
1260 max_age: Option<Duration>,
1261 now: SystemTime,
1262) -> SweepDisposition {
1263 if !path.exists() {
1275 return reclaim_orphan_cache_entry(repo_root, path);
1276 }
1277 let Some(max_age) = max_age else {
1278 return SweepDisposition::KeptAgeGcDisabled;
1279 };
1280 reclaim_aged_cache_entry(repo_root, path, max_age, now)
1281}
1282
1283pub fn reclaim_orphan_cache_entry(repo_root: &Path, path: &Path) -> SweepDisposition {
1289 let Some(_lock) = ReusableWorktreeLock::try_acquire(path, GC_LOCK_CONTEXT) else {
1290 return SweepDisposition::SkippedLocked;
1291 };
1292 if path.exists() {
1295 return SweepDisposition::KeptRecreated;
1296 }
1297 match remove_cache_entry_for_sweep(repo_root, path) {
1298 Ok(CacheRemovalOutcome::Removed) => SweepDisposition::ReclaimedOrphan,
1299 Ok(CacheRemovalOutcome::NothingLeft) => SweepDisposition::KeptLockOnly,
1300 Ok(CacheRemovalOutcome::NotOwned) => SweepDisposition::KeptNotOwned,
1301 Err(_) => SweepDisposition::RemoveFailed,
1302 }
1303}
1304
1305fn reclaim_aged_cache_entry(
1311 repo_root: &Path,
1312 path: &Path,
1313 max_age: Duration,
1314 now: SystemTime,
1315) -> SweepDisposition {
1316 let Some(mtime) = last_used_mtime(path) else {
1317 record_last_used(path, repo_root);
1318 return SweepDisposition::KeptGraceSeeded;
1319 };
1320 remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
1321}
1322
1323fn reclaim_foreign_cache_entry(
1338 repo_root: &Path,
1339 path: &Path,
1340 max_age: Option<Duration>,
1341 now: SystemTime,
1342) -> SweepDisposition {
1343 if !path.exists() {
1346 return reclaim_orphan_cache_entry(repo_root, path);
1347 }
1348 let mut owner_missing = false;
1349 if let Some(owner) = read_last_used_owner(path) {
1350 match probe_owner_liveness(&owner) {
1351 OwnerLiveness::Live => return SweepDisposition::KeptOwnerLive,
1352 OwnerLiveness::Unverifiable(kind) => {
1353 return SweepDisposition::KeptOwnerUnverifiable(kind);
1354 }
1355 OwnerLiveness::Dead => owner_missing = true,
1356 }
1357 }
1358 let Some(max_age) = max_age else {
1359 if owner_missing {
1360 return remove_cache_entry_under_lock(
1361 repo_root,
1362 path,
1363 SweepDisposition::ReclaimedOwnerMissing,
1364 );
1365 }
1366 return SweepDisposition::KeptAgeGcDisabled;
1367 };
1368 let Some(mtime) = last_used_mtime(path) else {
1369 touch_last_used(path);
1370 return SweepDisposition::KeptGraceSeeded;
1371 };
1372 remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
1373}
1374
1375enum OwnerLiveness {
1376 Live,
1377 Dead,
1378 Unverifiable(std::io::ErrorKind),
1379}
1380
1381fn probe_owner_liveness(owner: &Path) -> OwnerLiveness {
1391 match std::fs::metadata(owner) {
1392 Ok(_) => OwnerLiveness::Live,
1393 Err(error) if error.kind() == std::io::ErrorKind::NotFound => OwnerLiveness::Dead,
1394 Err(error) => OwnerLiveness::Unverifiable(error.kind()),
1395 }
1396}
1397
1398#[derive(Clone, Copy, PartialEq, Eq)]
1401enum OwnerGate {
1402 On,
1403 Off,
1404}
1405
1406fn classify_entry_dry_run(
1412 path: &Path,
1413 max_age: Option<Duration>,
1414 now: SystemTime,
1415 owner_gate: OwnerGate,
1416) -> SweepDisposition {
1417 if !path.exists() {
1418 if !reusable_cache_entry_exists(path) {
1419 return SweepDisposition::KeptLockOnly;
1420 }
1421 return classify_would_remove(path, SweepDisposition::ReclaimedOrphan);
1422 }
1423 let mut owner_missing = false;
1424 if owner_gate == OwnerGate::On
1425 && let Some(owner) = read_last_used_owner(path)
1426 {
1427 match probe_owner_liveness(&owner) {
1428 OwnerLiveness::Live => return SweepDisposition::KeptOwnerLive,
1429 OwnerLiveness::Unverifiable(kind) => {
1430 return SweepDisposition::KeptOwnerUnverifiable(kind);
1431 }
1432 OwnerLiveness::Dead => owner_missing = true,
1433 }
1434 }
1435 let Some(max_age) = max_age else {
1436 if owner_missing {
1437 return classify_would_remove(path, SweepDisposition::ReclaimedOwnerMissing);
1438 }
1439 return SweepDisposition::KeptAgeGcDisabled;
1440 };
1441 let Some(mtime) = last_used_mtime(path) else {
1442 return SweepDisposition::KeptGraceSeeded;
1443 };
1444 let Ok(age) = now.duration_since(mtime) else {
1445 return SweepDisposition::KeptFresh;
1446 };
1447 if age < max_age {
1448 return SweepDisposition::KeptFresh;
1449 }
1450 classify_would_remove(path, SweepDisposition::ReclaimedAged)
1451}
1452
1453fn classify_would_remove(path: &Path, removed: SweepDisposition) -> SweepDisposition {
1457 match cache_entry_ownership(path) {
1458 Ok(CacheEntryOwnership::Owned) => removed,
1459 Ok(CacheEntryOwnership::Unowned(_)) => SweepDisposition::KeptNotOwned,
1460 Err(_) => SweepDisposition::RemoveFailed,
1461 }
1462}
1463
1464fn last_used_mtime(path: &Path) -> Option<SystemTime> {
1465 std::fs::metadata(reusable_worktree_last_used_path(path))
1466 .ok()
1467 .and_then(|metadata| metadata.modified().ok())
1468}
1469
1470fn remove_entry_past_max_age(
1471 repo_root: &Path,
1472 path: &Path,
1473 max_age: Duration,
1474 now: SystemTime,
1475 mtime: SystemTime,
1476) -> SweepDisposition {
1477 let Ok(age) = now.duration_since(mtime) else {
1478 return SweepDisposition::KeptFresh;
1479 };
1480 if age < max_age {
1481 return SweepDisposition::KeptFresh;
1482 }
1483 remove_cache_entry_under_lock(repo_root, path, SweepDisposition::ReclaimedAged)
1484}
1485
1486fn remove_cache_entry_under_lock(
1488 repo_root: &Path,
1489 path: &Path,
1490 removed: SweepDisposition,
1491) -> SweepDisposition {
1492 let Some(_lock) = ReusableWorktreeLock::try_acquire(path, GC_LOCK_CONTEXT) else {
1493 return SweepDisposition::SkippedLocked;
1494 };
1495 match remove_cache_entry_for_sweep(repo_root, path) {
1496 Ok(CacheRemovalOutcome::Removed) => removed,
1497 Ok(CacheRemovalOutcome::NothingLeft) => SweepDisposition::KeptLockOnly,
1498 Ok(CacheRemovalOutcome::NotOwned) => SweepDisposition::KeptNotOwned,
1499 Err(err) => {
1500 tracing::warn!(
1501 path = %path.display(),
1502 error = %err,
1503 "failed to remove stale reusable audit worktree entry; entry may leak",
1504 );
1505 SweepDisposition::RemoveFailed
1506 }
1507 }
1508}
1509
1510enum CacheRemovalOutcome {
1511 Removed,
1512 NothingLeft,
1514 NotOwned,
1516}
1517
1518fn remove_cache_entry_for_sweep(
1522 repo_root: &Path,
1523 path: &Path,
1524) -> std::io::Result<CacheRemovalOutcome> {
1525 if matches!(
1526 cache_entry_ownership(path)?,
1527 CacheEntryOwnership::Unowned(_)
1528 ) {
1529 return Ok(CacheRemovalOutcome::NotOwned);
1530 }
1531 remove_reusable_cache_entry_locked(repo_root, path).map(|removed| {
1532 if removed {
1533 CacheRemovalOutcome::Removed
1534 } else {
1535 CacheRemovalOutcome::NothingLeft
1536 }
1537 })
1538}
1539
1540fn directory_size_bytes(root: &Path) -> Option<u64> {
1549 let root_entries = std::fs::read_dir(root).ok()?;
1550 let mut total: u64 = 0;
1551 let mut stack = vec![root_entries];
1552 while let Some(entries) = stack.pop() {
1553 for entry in entries.flatten() {
1554 let Ok(metadata) = entry.metadata() else {
1555 continue;
1556 };
1557 if metadata.is_dir() {
1558 if let Ok(child) = std::fs::read_dir(entry.path()) {
1559 stack.push(child);
1560 }
1561 } else if metadata_is_regular_file(&metadata) {
1562 total = total.saturating_add(metadata.len());
1563 }
1564 }
1565 }
1566 Some(total)
1567}
1568
1569fn measure_entry_sizes<'a>(
1572 sizes: SweepSizes,
1573 paths: impl Iterator<Item = &'a PathBuf>,
1574) -> FxHashMap<PathBuf, Option<u64>> {
1575 use rayon::iter::{IntoParallelRefIterator as _, ParallelIterator as _};
1576
1577 if sizes == SweepSizes::Skip {
1578 return FxHashMap::default();
1579 }
1580 let paths: Vec<&PathBuf> = paths.collect();
1581 paths
1582 .par_iter()
1583 .map(|path| ((*path).clone(), directory_size_bytes(path)))
1584 .collect()
1585}
1586
1587pub fn canonical_root_hash(root: &Path) -> u64 {
1588 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1589 xxh3_64(&path_identity_bytes(&canonical_root))
1590}
1591
1592#[cfg(unix)]
1593fn path_identity_bytes(path: &Path) -> Vec<u8> {
1594 use std::os::unix::ffi::OsStrExt as _;
1595
1596 path.as_os_str().as_bytes().to_vec()
1597}
1598
1599#[cfg(windows)]
1600fn path_identity_bytes(path: &Path) -> Vec<u8> {
1601 use std::os::windows::ffi::OsStrExt as _;
1602
1603 path.as_os_str()
1604 .encode_wide()
1605 .flat_map(u16::to_le_bytes)
1606 .collect()
1607}
1608
1609#[cfg(not(any(unix, windows)))]
1610fn path_identity_bytes(path: &Path) -> Vec<u8> {
1611 path.to_string_lossy().as_bytes().to_vec()
1612}
1613
1614pub fn reusable_audit_worktree_path(requested_root: &Path) -> PathBuf {
1615 let root_hash = canonical_root_hash(requested_root);
1616 let repo_hash = git_toplevel(requested_root)
1617 .as_deref()
1618 .map_or(root_hash, canonical_root_hash);
1619 std::env::temp_dir().join(format!(
1620 "fallow-audit-base-cache-{repo_hash:016x}-root-{root_hash:016x}"
1621 ))
1622}
1623
1624fn root_owned_cache_repo_prefix(requested_root: &Path) -> Option<String> {
1625 let git_root = git_toplevel(requested_root)?;
1626 let repo_hash = canonical_root_hash(&git_root);
1627 Some(format!("fallow-audit-base-cache-{repo_hash:016x}-root-"))
1628}
1629
1630fn legacy_reusable_cache_repo_prefix(requested_root: &Path) -> Option<String> {
1631 let git_root = git_toplevel(requested_root)?;
1632 let repo_hash = canonical_root_hash(&git_root);
1633 Some(format!("fallow-audit-base-cache-{repo_hash:016x}-"))
1634}
1635
1636#[cfg(test)]
1637pub fn legacy_reusable_audit_worktree_path(
1638 requested_root: &Path,
1639 base_sha: &str,
1640) -> Option<PathBuf> {
1641 let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
1642 Some(std::env::temp_dir().join(format!(
1643 "{}{sha_prefix}",
1644 legacy_reusable_cache_repo_prefix(requested_root)?
1645 )))
1646}
1647
1648fn reusable_audit_worktree_is_ready(path: &Path, base_sha: &str) -> bool {
1659 if !reusable_cache_directory_is_trusted(path) {
1660 return false;
1661 }
1662 let recorded = read_reusable_sha(path);
1663 if recorded.as_deref() != Some(base_sha) {
1664 return false;
1665 }
1666 repair_unregistered_git_stub(path)
1667}
1668
1669fn read_reusable_sha(path: &Path) -> Option<String> {
1670 const MAX_SHA_SIDECAR_BYTES: u64 = 129;
1671
1672 let sidecar = reusable_worktree_sha_path(path);
1673 let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
1674 if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_SHA_SIDECAR_BYTES {
1675 return None;
1676 }
1677 let mut contents = String::new();
1678 std::fs::File::open(sidecar)
1679 .ok()?
1680 .take(MAX_SHA_SIDECAR_BYTES)
1681 .read_to_string(&mut contents)
1682 .ok()?;
1683 Some(contents.trim().to_owned())
1684}
1685
1686#[cfg(unix)]
1687fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
1688 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1689
1690 let Ok(metadata) = std::fs::symlink_metadata(path) else {
1691 return false;
1692 };
1693 metadata.file_type().is_dir()
1694 && metadata.uid() == rustix::process::geteuid().as_raw()
1695 && metadata.permissions().mode().trailing_zeros() >= 6
1696}
1697
1698#[cfg(not(unix))]
1699fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
1700 std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
1701}
1702
1703fn try_migrate_registered_current_cache(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
1709 if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
1710 return false;
1711 }
1712 let head_matches = git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha);
1713 if !head_matches || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
1714 {
1715 return false;
1716 }
1717 if unregister_worktree_checked(repo_root, path).is_err() {
1718 return false;
1719 }
1720 write_reusable_sha(path, base_sha).is_ok()
1721}
1722
1723#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1730pub struct AuditCacheRemovalReport {
1731 pub found: usize,
1732 pub removed: usize,
1733 pub skipped: usize,
1734 pub dry_run: bool,
1735}
1736
1737pub fn remove_reusable_audit_caches(
1738 requested_root: &Path,
1739 dry_run: bool,
1740) -> std::io::Result<AuditCacheRemovalReport> {
1741 let mut paths = vec![reusable_audit_worktree_path(requested_root)];
1742 paths.extend(scan_legacy_reusable_cache_paths(
1743 requested_root,
1744 &std::env::temp_dir(),
1745 ));
1746 if git_toplevel(requested_root).is_some_and(|root| paths_equal(&root, requested_root)) {
1747 paths.extend(scan_root_owned_cache_paths(requested_root));
1748 }
1749 paths.sort();
1750 paths.dedup();
1751
1752 let mut report = AuditCacheRemovalReport {
1753 found: 0,
1754 removed: 0,
1755 skipped: 0,
1756 dry_run,
1757 };
1758 for path in paths {
1759 if !reusable_cache_entry_exists(&path) {
1760 continue;
1761 }
1762 report.found += 1;
1763 if dry_run {
1764 continue;
1769 }
1770 let Some(_lock) =
1771 ReusableWorktreeLock::try_acquire(&path, "cache removal reports the entry as skipped")
1772 else {
1773 report.skipped += 1;
1774 continue;
1775 };
1776 if remove_reusable_cache_entry_locked(requested_root, &path)? {
1777 report.removed += 1;
1778 }
1779 }
1780 Ok(report)
1781}
1782
1783fn reusable_cache_entry_exists(path: &Path) -> bool {
1784 path_entry_exists(path)
1785 || path_entry_exists(&reusable_worktree_sha_path(path))
1786 || path_entry_exists(&reusable_worktree_last_used_path(path))
1787}
1788
1789fn cache_entry_has_presence(path: &Path) -> bool {
1793 reusable_cache_entry_exists(path) || path_entry_exists(&reusable_worktree_lock_path(path))
1794}
1795
1796fn path_entry_exists(path: &Path) -> bool {
1797 std::fs::symlink_metadata(path).is_ok()
1798}
1799
1800fn remove_reusable_cache_entry_locked(repo_root: &Path, path: &Path) -> std::io::Result<bool> {
1803 let existed = reusable_cache_entry_exists(path);
1804 ensure_cache_entry_is_owned(path)?;
1805 if trusted_worktree_admin_dir(repo_root, path).is_some() {
1806 unregister_worktree_checked(repo_root, path)?;
1807 }
1808 remove_dir_if_exists(path)?;
1809 remove_file_if_exists(&reusable_worktree_sha_path(path))?;
1810 remove_file_if_exists(&reusable_worktree_last_used_path(path))?;
1811 Ok(existed)
1812}
1813
1814enum CacheEntryOwnership {
1815 Owned,
1816 #[cfg_attr(
1817 not(unix),
1818 expect(dead_code, reason = "only the Unix uid probe constructs this variant")
1819 )]
1820 Unowned(PathBuf),
1821}
1822
1823#[cfg(unix)]
1824fn cache_entry_ownership(path: &Path) -> std::io::Result<CacheEntryOwnership> {
1825 use std::os::unix::fs::MetadataExt as _;
1826
1827 let effective_uid = rustix::process::geteuid().as_raw();
1828 for entry in [
1829 path.to_path_buf(),
1830 reusable_worktree_sha_path(path),
1831 reusable_worktree_last_used_path(path),
1832 ] {
1833 let metadata = match std::fs::symlink_metadata(&entry) {
1834 Ok(metadata) => metadata,
1835 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1836 Err(error) => return Err(error),
1837 };
1838 if metadata.uid() != effective_uid {
1839 return Ok(CacheEntryOwnership::Unowned(entry));
1840 }
1841 }
1842 Ok(CacheEntryOwnership::Owned)
1843}
1844
1845#[cfg(not(unix))]
1846#[expect(
1847 clippy::unnecessary_wraps,
1848 reason = "shared cross-platform signature; the Unix ownership check is fallible, non-Unix has no POSIX owner to verify"
1849)]
1850fn cache_entry_ownership(_path: &Path) -> std::io::Result<CacheEntryOwnership> {
1851 Ok(CacheEntryOwnership::Owned)
1852}
1853
1854fn ensure_cache_entry_is_owned(path: &Path) -> std::io::Result<()> {
1855 match cache_entry_ownership(path)? {
1856 CacheEntryOwnership::Owned => Ok(()),
1857 CacheEntryOwnership::Unowned(entry) => Err(std::io::Error::new(
1858 std::io::ErrorKind::PermissionDenied,
1859 format!(
1860 "refusing to remove unowned audit cache entry `{}`",
1861 entry.display()
1862 ),
1863 )),
1864 }
1865}
1866
1867fn remove_dir_if_exists(path: &Path) -> std::io::Result<()> {
1868 match std::fs::remove_dir_all(path) {
1869 Ok(()) => Ok(()),
1870 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1871 Err(err) => Err(err),
1872 }
1873}
1874
1875fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
1876 match std::fs::remove_file(path) {
1877 Ok(()) => Ok(()),
1878 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1879 Err(err) => Err(err),
1880 }
1881}
1882
1883pub fn unregister_worktree(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1893 unregister_worktree_checked(repo_root, path)
1894}
1895
1896fn unregister_worktree_checked(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1897 if !path.exists() {
1898 return Ok(());
1899 }
1900 let gitfile = path.join(".git");
1901 let metadata = std::fs::symlink_metadata(&gitfile)?;
1902 if !metadata_is_regular_file(&metadata) {
1903 return Err(std::io::Error::new(
1904 std::io::ErrorKind::InvalidData,
1905 "refusing to deregister through a non-file audit worktree .git entry",
1906 ));
1907 }
1908 let contents = std::fs::read_to_string(&gitfile)?;
1909 if contents == UNREGISTERED_GITDIR_STUB {
1910 return Ok(());
1911 }
1912 let Some(admin_dir) = trusted_worktree_admin_dir(repo_root, path) else {
1913 return Err(std::io::Error::new(
1914 std::io::ErrorKind::InvalidData,
1915 "refusing to deregister an unverified audit worktree admin entry",
1916 ));
1917 };
1918 remove_dir_if_exists(&admin_dir)?;
1919 write_git_stub_safely(&gitfile)
1920}
1921
1922fn repair_unregistered_git_stub(path: &Path) -> bool {
1927 let gitfile = path.join(".git");
1928 let Ok(metadata) = std::fs::symlink_metadata(&gitfile) else {
1929 return write_git_stub_safely(&gitfile).is_ok();
1930 };
1931 if !metadata_is_regular_file(&metadata) {
1932 return false;
1933 }
1934 std::fs::read_to_string(&gitfile).is_ok_and(|contents| contents == UNREGISTERED_GITDIR_STUB)
1935}
1936
1937fn write_git_stub_safely(gitfile: &Path) -> std::io::Result<()> {
1938 let mut options = std::fs::OpenOptions::new();
1939 options.write(true).truncate(true);
1940 match std::fs::symlink_metadata(gitfile) {
1941 Ok(metadata) if metadata_is_regular_file(&metadata) => {}
1942 Ok(_) => {
1943 return Err(std::io::Error::new(
1944 std::io::ErrorKind::InvalidData,
1945 "refusing to replace non-file audit worktree .git entry",
1946 ));
1947 }
1948 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1949 options.create_new(true);
1950 }
1951 Err(error) => return Err(error),
1952 }
1953 let mut file = options.open(gitfile)?;
1954 file.write_all(UNREGISTERED_GITDIR_STUB.as_bytes())?;
1955 file.sync_all()
1956}
1957
1958fn trusted_worktree_admin_dir(repo_root: &Path, path: &Path) -> Option<PathBuf> {
1959 let gitfile = path.join(".git");
1960 let metadata = std::fs::symlink_metadata(&gitfile).ok()?;
1961 if !metadata_is_regular_file(&metadata) {
1962 return None;
1963 }
1964 let contents = std::fs::read_to_string(&gitfile).ok()?;
1965 let admin_dir = parse_worktree_gitdir(&contents)?;
1966 if !is_fallow_admin_dir(&admin_dir) {
1967 return None;
1968 }
1969 let common_dir = fallow_engine::changed_files::resolve_git_common_dir(repo_root).ok()?;
1970 let worktrees_dir = dunce::canonicalize(common_dir.join("worktrees")).ok()?;
1971 let admin_parent = dunce::canonicalize(admin_dir.parent()?).ok()?;
1972 if admin_parent != worktrees_dir {
1973 return None;
1974 }
1975 let backlink = std::fs::read_to_string(admin_dir.join("gitdir")).ok()?;
1976 let expected_gitfile = dunce::canonicalize(&gitfile).ok()?;
1977 let actual_gitfile = dunce::canonicalize(Path::new(backlink.trim())).ok()?;
1978 (actual_gitfile == expected_gitfile).then_some(admin_dir)
1979}
1980
1981fn parse_worktree_gitdir(contents: &str) -> Option<PathBuf> {
1983 contents
1984 .lines()
1985 .find_map(|line| line.trim().strip_prefix("gitdir:"))
1986 .map(|rest| PathBuf::from(rest.trim()))
1987}
1988
1989fn is_fallow_admin_dir(admin_dir: &Path) -> bool {
1992 admin_dir
1993 .file_name()
1994 .and_then(|name| name.to_str())
1995 .is_some_and(|name| name.starts_with("fallow-audit-base-"))
1996}
1997
1998pub fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
1999 let mut command = Command::new("git");
2000 command.args(["rev-parse", rev]).current_dir(root);
2001 clear_ambient_git_env(&mut command);
2002 let output = command.output().ok()?;
2003 if !output.status.success() {
2004 return None;
2005 }
2006 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
2007}
2008
2009pub fn git_toplevel(root: &Path) -> Option<PathBuf> {
2010 let mut command = Command::new("git");
2011 command
2012 .args(["rev-parse", "--show-toplevel"])
2013 .current_dir(root);
2014 clear_ambient_git_env(&mut command);
2015 let output = command.output().ok()?;
2016 if !output.status.success() {
2017 return None;
2018 }
2019 let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
2020 Some(dunce::canonicalize(&path).unwrap_or(path))
2021}
2022
2023fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
2024 let Some(worktrees) = list_audit_worktrees(repo_root) else {
2025 return false;
2026 };
2027 worktrees.iter().any(|worktree| paths_equal(worktree, path))
2028}
2029
2030pub fn paths_equal(left: &Path, right: &Path) -> bool {
2031 if left == right {
2032 return true;
2033 }
2034 match (dunce::canonicalize(left), dunce::canonicalize(right)) {
2035 (Ok(left), Ok(right)) => left == right,
2036 _ => false,
2037 }
2038}
2039
2040pub fn remove_audit_worktree(repo_root: &Path, path: &Path) {
2041 let mut command = Command::new("git");
2042 command
2043 .args([
2044 "worktree",
2045 "remove",
2046 "--force",
2047 path.to_string_lossy().as_ref(),
2048 ])
2049 .current_dir(repo_root);
2050 clear_ambient_git_env(&mut command);
2051 match crate::signal::scoped_child::output(&mut command) {
2052 Ok(output) => {
2053 if !output.status.success() && path.exists() {
2054 let stderr = String::from_utf8_lossy(&output.stderr);
2055 tracing::warn!(
2056 path = %path.display(),
2057 stderr = %stderr.trim(),
2058 "git worktree remove failed; the directory remains and may leak",
2059 );
2060 }
2061 }
2062 Err(err) => {
2063 tracing::warn!(
2064 path = %path.display(),
2065 error = %err,
2066 "git worktree remove subprocess failed to spawn",
2067 );
2068 }
2069 }
2070}
2071
2072pub fn sweep_orphan_audit_worktrees(repo_root: &Path) {
2073 sweep_orphan_audit_worktrees_in(repo_root, &std::env::temp_dir());
2074}
2075
2076pub fn sweep_orphan_audit_worktrees_in(repo_root: &Path, temp_root: &Path) {
2082 if deregister_legacy_orphan_worktrees(repo_root) {
2087 let mut command = Command::new("git");
2088 command
2089 .args(["worktree", "prune", "--expire=now"])
2090 .current_dir(repo_root);
2091 clear_ambient_git_env(&mut command);
2092 let _ = command.output();
2093 }
2094
2095 for path in scan_non_reusable_orphan_paths(temp_root) {
2099 let _ = std::fs::remove_dir_all(&path);
2100 }
2101}
2102
2103fn deregister_legacy_orphan_worktrees(repo_root: &Path) -> bool {
2106 let Some(worktrees) = list_audit_worktrees(repo_root) else {
2107 return false;
2108 };
2109 let mut removed_any = false;
2110 for path in worktrees {
2111 if !is_fallow_audit_worktree_path(&path)
2112 || is_reusable_audit_worktree_path(&path)
2113 || audit_worktree_process_is_alive(&path)
2114 {
2115 continue;
2116 }
2117 remove_audit_worktree(repo_root, &path);
2118 let _ = std::fs::remove_dir_all(&path);
2119 removed_any = true;
2120 }
2121 removed_any
2122}
2123
2124fn scan_non_reusable_orphan_paths(temp: &Path) -> Vec<PathBuf> {
2127 let Ok(entries) = std::fs::read_dir(temp) else {
2128 return Vec::new();
2129 };
2130 let mut paths = Vec::new();
2131 for entry in entries.flatten() {
2132 let name = entry.file_name();
2133 let Some(name) = name.to_str() else {
2134 continue;
2135 };
2136 let Some(pid) = audit_worktree_pid(name) else {
2137 continue;
2138 };
2139 if process_is_alive(pid) || !entry.path().is_dir() {
2140 continue;
2141 }
2142 paths.push(temp.join(name));
2143 }
2144 paths
2145}
2146
2147pub fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
2148 let mut command = Command::new("git");
2149 command
2150 .args(["worktree", "list", "--porcelain"])
2151 .current_dir(repo_root);
2152 clear_ambient_git_env(&mut command);
2153 let output = command.output().ok()?;
2154 if !output.status.success() {
2155 return None;
2156 }
2157 Some(parse_worktree_list(&String::from_utf8_lossy(
2158 &output.stdout,
2159 )))
2160}
2161
2162pub fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
2163 output
2164 .lines()
2165 .filter_map(|line| line.strip_prefix("worktree "))
2166 .map(PathBuf::from)
2167 .filter(|path| is_fallow_audit_worktree_path(path))
2168 .collect()
2169}
2170
2171pub fn is_fallow_audit_worktree_path(path: &Path) -> bool {
2172 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
2173 return false;
2174 };
2175 name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
2176}
2177
2178pub fn is_reusable_audit_worktree_path(path: &Path) -> bool {
2179 path.file_name()
2180 .and_then(|name| name.to_str())
2181 .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
2182}
2183
2184fn path_is_inside_temp_dir(path: &Path) -> bool {
2185 let temp = std::env::temp_dir();
2186 let simple_path = dunce::simplified(path);
2187 let simple_temp = dunce::simplified(&temp);
2188 if simple_path.starts_with(simple_temp) {
2189 return true;
2190 }
2191 let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
2192 return false;
2193 };
2194 let simple_canonical_temp = dunce::simplified(&canonical_temp);
2195 simple_path.starts_with(simple_canonical_temp)
2196 || std::fs::canonicalize(path).is_ok_and(|canonical_path| {
2197 dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
2198 })
2199}
2200
2201fn audit_worktree_process_is_alive(path: &Path) -> bool {
2202 let Some(pid) = path
2203 .file_name()
2204 .and_then(|name| name.to_str())
2205 .and_then(audit_worktree_pid)
2206 else {
2207 return false;
2208 };
2209 process_is_alive(pid)
2210}
2211
2212pub fn audit_worktree_pid(name: &str) -> Option<u32> {
2213 name.strip_prefix("fallow-audit-base-")?
2214 .split('-')
2215 .next()?
2216 .parse()
2217 .ok()
2218}
2219
2220#[cfg(unix)]
2221pub fn process_is_alive(pid: u32) -> bool {
2222 Command::new("kill")
2223 .args(["-0", &pid.to_string()])
2224 .output()
2225 .is_ok_and(|output| output.status.success())
2226}
2227
2228#[cfg(windows)]
2229pub fn process_is_alive(pid: u32) -> bool {
2230 windows_process::is_alive(pid)
2231}
2232
2233#[cfg(not(any(unix, windows)))]
2234pub fn process_is_alive(_pid: u32) -> bool {
2235 true
2236}
2237
2238#[cfg(windows)]
2239#[allow(
2240 unsafe_code,
2241 reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
2242)]
2243mod windows_process {
2244 use windows_sys::Win32::Foundation::{
2245 CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
2246 WAIT_OBJECT_0,
2247 };
2248 use windows_sys::Win32::System::Threading::{
2249 OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
2250 };
2251
2252 struct ProcessHandle(HANDLE);
2256
2257 impl Drop for ProcessHandle {
2258 fn drop(&mut self) {
2259 unsafe {
2263 CloseHandle(self.0);
2264 }
2265 }
2266 }
2267
2268 pub fn is_alive(pid: u32) -> bool {
2276 let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
2280 if raw.is_null() {
2281 let err = unsafe { GetLastError() };
2284 #[expect(
2285 clippy::match_same_arms,
2286 reason = "named arm documents the cross-session case"
2287 )]
2288 return match err {
2289 ERROR_INVALID_PARAMETER => false,
2290 ERROR_ACCESS_DENIED => true,
2291 _ => true,
2292 };
2293 }
2294 let handle = ProcessHandle(raw);
2295 let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
2298 wait_result != WAIT_OBJECT_0
2299 }
2300}
2301
2302impl Drop for BaseWorktree {
2303 fn drop(&mut self) {
2304 if self.persistent {
2305 return;
2306 }
2307 let _ = std::fs::remove_dir_all(&self.path);
2311 }
2312}
2313
2314#[cfg(test)]
2315mod tests {
2316 use super::*;
2317
2318 #[test]
2324 fn non_reusable_worktree_paths_are_unique_under_concurrency() {
2325 const N: usize = 64;
2326 let barrier = std::sync::Barrier::new(N);
2327 let paths = std::sync::Mutex::new(Vec::with_capacity(N));
2328 std::thread::scope(|s| {
2329 for _ in 0..N {
2330 let barrier = &barrier;
2331 let paths = &paths;
2332 s.spawn(move || {
2333 barrier.wait();
2334 let path = non_reusable_worktree_path().expect("path should build");
2335 paths.lock().unwrap().push(path);
2336 });
2337 }
2338 });
2339 let mut paths = paths.into_inner().unwrap();
2340 assert_eq!(paths.len(), N);
2341 paths.sort();
2342 paths.dedup();
2343 assert_eq!(paths.len(), N, "non-reusable worktree paths collided");
2344 }
2345
2346 #[test]
2348 fn non_reusable_worktree_path_pid_is_parseable() {
2349 let path = non_reusable_worktree_path().expect("path should build");
2350 let name = path.file_name().unwrap().to_str().unwrap();
2351 assert!(is_fallow_audit_worktree_path(&path));
2352 assert!(!is_reusable_audit_worktree_path(&path));
2353 assert_eq!(audit_worktree_pid(name), Some(std::process::id()));
2354 }
2355
2356 #[test]
2357 fn directory_size_bytes_sums_regular_files_recursively() {
2358 let temp = tempfile::TempDir::new().expect("temp dir should be created");
2359 let root = temp.path().join("cache");
2360 std::fs::create_dir_all(root.join("node_modules/dep")).expect("tree should be created");
2361 std::fs::write(root.join("a.txt"), vec![0u8; 10]).expect("file should be written");
2362 std::fs::write(root.join("node_modules/dep/b.js"), vec![0u8; 32])
2363 .expect("nested file should be written");
2364 std::fs::write(root.join(".gitignore"), "node_modules\n")
2367 .expect("gitignore should be written");
2368
2369 let size = directory_size_bytes(&root).expect("size walk should succeed");
2370 assert_eq!(size, 10 + 32 + "node_modules\n".len() as u64);
2371 assert_eq!(
2372 directory_size_bytes(&temp.path().join("missing")),
2373 None,
2374 "an absent directory reports no size",
2375 );
2376 }
2377
2378 #[cfg(unix)]
2379 #[test]
2380 fn directory_size_bytes_never_follows_symlinks() {
2381 let temp = tempfile::TempDir::new().expect("temp dir should be created");
2382 let root = temp.path().join("cache");
2383 let outside = temp.path().join("outside");
2384 std::fs::create_dir_all(&root).expect("root should be created");
2385 std::fs::create_dir_all(&outside).expect("outside dir should be created");
2386 std::fs::write(outside.join("big.bin"), vec![0u8; 4096])
2387 .expect("outside file should be written");
2388 std::os::unix::fs::symlink(&outside, root.join("link-dir"))
2389 .expect("dir symlink should be created");
2390 std::os::unix::fs::symlink(outside.join("big.bin"), root.join("link-file"))
2391 .expect("file symlink should be created");
2392
2393 assert_eq!(
2394 directory_size_bytes(&root),
2395 Some(0),
2396 "symlinked directories and files must not be traversed or counted",
2397 );
2398 }
2399
2400 #[cfg(unix)]
2401 #[test]
2402 fn cache_sidecar_open_does_not_follow_symlinks() {
2403 let temp = tempfile::TempDir::new().expect("temp dir should be created");
2404 let victim = temp.path().join("victim");
2405 let sidecar = temp.path().join("cache.lock");
2406 std::fs::write(&victim, "unchanged\n").expect("victim should be written");
2407 std::os::unix::fs::symlink(&victim, &sidecar).expect("sidecar symlink should be created");
2408
2409 assert!(open_or_create_owned_sidecar(&sidecar).is_err());
2410 assert_eq!(
2411 std::fs::read_to_string(victim).expect("victim should remain readable"),
2412 "unchanged\n",
2413 );
2414 }
2415}