1use std::io::{Read, Write};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{Duration, SystemTime};
6
7use fallow_engine::changed_files::clear_ambient_git_env;
8pub use fallow_engine::repo_refs::materialize_base_dependency_context;
9use rustc_hash::{FxHashMap, FxHashSet};
10use xxhash_rust::xxh3::xxh3_64;
11
12use crate::report::plural;
13
14pub struct BaseWorktree {
15 path: PathBuf,
16 persistent: bool,
17 _reusable_lock: Option<ReusableWorktreeLock>,
18}
19
20impl BaseWorktree {
21 pub fn create(repo_root: &Path, base_ref: &str, base_sha: Option<&str>) -> Option<Self> {
22 sweep_orphan_audit_worktrees(repo_root);
23 if let Some(base_sha) = base_sha
24 && let Some(worktree) = Self::reuse_or_create(repo_root, base_sha)
25 {
26 return Some(worktree);
27 }
28 let path = non_reusable_worktree_path()?;
29 let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
30 if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
31 repo_root,
32 guard.path(),
33 base_ref,
34 ) {
35 tracing::debug!(
36 base_ref,
37 error = %error,
38 "could not materialize non-reusable audit base worktree",
39 );
40 return None;
41 }
42 if let Err(error) = unregister_worktree(repo_root, guard.path()) {
47 tracing::debug!(
48 path = %guard.path().display(),
49 error = %error,
50 "could not deregister non-reusable audit base worktree",
51 );
52 return None;
53 }
54 guard.defuse();
55 drop(guard);
56 let worktree = Self {
57 path,
58 persistent: false,
59 _reusable_lock: None,
60 };
61 materialize_base_dependency_context(repo_root, worktree.path());
62 Some(worktree)
63 }
64
65 pub fn reuse_or_create(repo_root: &Path, base_sha: &str) -> Option<Self> {
66 let path = reusable_audit_worktree_path(repo_root);
67 let reusable_lock =
68 ReusableWorktreeLock::try_acquire(&path, "falling back to non-reusable worktree")?;
69
70 if reusable_audit_worktree_is_ready(&path, base_sha)
71 || try_migrate_registered_current_cache(repo_root, &path, base_sha)
72 {
73 let worktree = Self {
74 path,
75 persistent: true,
76 _reusable_lock: Some(reusable_lock),
77 };
78 materialize_base_dependency_context(repo_root, worktree.path());
79 record_last_used(worktree.path(), repo_root);
80 return Some(worktree);
81 }
82
83 if let Err(error) = remove_file_if_exists(&reusable_worktree_sha_path(&path)) {
84 tracing::debug!(
85 path = %path.display(),
86 error = %error,
87 "could not clear reusable audit worktree readiness before rebuild",
88 );
89 return None;
90 }
91 if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
92 tracing::debug!(
93 path = %path.display(),
94 error = %error,
95 "could not remove stale reusable audit worktree before rebuild",
96 );
97 return None;
98 }
99 let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
100 if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
101 repo_root,
102 guard.path(),
103 base_sha,
104 ) {
105 tracing::debug!(
106 base_sha,
107 error = %error,
108 "could not materialize reusable audit base worktree",
109 );
110 return None;
111 }
112 if let Err(error) = unregister_worktree_checked(repo_root, guard.path()) {
117 tracing::debug!(
118 path = %guard.path().display(),
119 error = %error,
120 "could not deregister reusable audit base worktree",
121 );
122 return None;
123 }
124 guard.defuse();
125 drop(guard);
126 let readiness_published = write_reusable_sha(&path, base_sha).is_ok();
127
128 let worktree = Self {
129 path,
130 persistent: true,
131 _reusable_lock: Some(reusable_lock),
132 };
133 materialize_base_dependency_context(repo_root, worktree.path());
134 if readiness_published {
135 record_last_used(worktree.path(), repo_root);
136 }
137 Some(worktree)
138 }
139
140 pub fn path(&self) -> &Path {
141 &self.path
142 }
143}
144
145fn non_reusable_worktree_path() -> Option<PathBuf> {
155 static SEQ: AtomicU64 = AtomicU64::new(0);
156 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
157 let nanos = SystemTime::now()
158 .duration_since(SystemTime::UNIX_EPOCH)
159 .ok()?
160 .as_nanos();
161 Some(std::env::temp_dir().join(format!(
162 "fallow-audit-base-{}-{nanos}-{seq}",
163 std::process::id()
164 )))
165}
166
167pub struct WorktreeCleanupGuard<'a> {
179 repo_root: PathBuf,
180 path: &'a Path,
181 armed: bool,
182}
183
184impl<'a> WorktreeCleanupGuard<'a> {
185 pub fn new(repo_root: &Path, path: &'a Path) -> Self {
186 Self {
187 repo_root: repo_root.to_path_buf(),
188 path,
189 armed: true,
190 }
191 }
192
193 pub fn path(&self) -> &Path {
194 self.path
195 }
196
197 pub fn defuse(&mut self) {
200 self.armed = false;
201 }
202}
203
204impl Drop for WorktreeCleanupGuard<'_> {
205 fn drop(&mut self) {
206 if self.armed {
207 remove_audit_worktree(&self.repo_root, self.path);
208 let _ = std::fs::remove_dir_all(self.path);
209 }
210 }
211}
212
213pub struct ReusableWorktreeLock {
219 file: std::fs::File,
220}
221
222impl ReusableWorktreeLock {
223 pub fn try_acquire(reusable_path: &Path, context: &'static str) -> Option<Self> {
227 let lock_path = reusable_worktree_lock_path(reusable_path);
228 let file = open_or_create_owned_sidecar(&lock_path).ok()?;
229 match file.try_lock() {
230 Ok(()) => Some(Self { file }),
231 Err(std::fs::TryLockError::WouldBlock) => {
232 tracing::debug!(
233 path = %lock_path.display(),
234 context,
235 "reusable audit worktree lock contended",
236 );
237 None
238 }
239 Err(std::fs::TryLockError::Error(err)) => {
240 tracing::debug!(
241 path = %lock_path.display(),
242 error = %err,
243 context,
244 "could not acquire reusable audit worktree lock",
245 );
246 None
247 }
248 }
249 }
250}
251
252impl Drop for ReusableWorktreeLock {
253 fn drop(&mut self) {
254 let _ = self.file.unlock();
255 }
256}
257
258pub fn reusable_worktree_lock_path(reusable_path: &Path) -> PathBuf {
259 sidecar_path(reusable_path, REUSABLE_LOCK_SUFFIX)
260}
261
262fn sidecar_path(reusable_path: &Path, suffix: &str) -> PathBuf {
266 let mut name = reusable_path
267 .file_name()
268 .map(std::ffi::OsString::from)
269 .unwrap_or_default();
270 name.push(suffix);
271 reusable_path
272 .parent()
273 .map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
274}
275
276pub fn reusable_worktree_sha_path(reusable_path: &Path) -> PathBuf {
280 sidecar_path(reusable_path, REUSABLE_SHA_SUFFIX)
281}
282
283fn write_reusable_sha(reusable_path: &Path, base_sha: &str) -> std::io::Result<()> {
286 static SEQ: AtomicU64 = AtomicU64::new(0);
287
288 let sha_path = reusable_worktree_sha_path(reusable_path);
289 let sequence = SEQ.fetch_add(1, Ordering::Relaxed);
290 let temp_path = sidecar_path(
291 reusable_path,
292 &format!(
293 "{REUSABLE_SHA_SUFFIX}.tmp-{}-{sequence}",
294 std::process::id()
295 ),
296 );
297 let result = (|| {
298 let mut options = std::fs::OpenOptions::new();
299 options.create_new(true).write(true);
300 #[cfg(unix)]
301 {
302 use std::os::unix::fs::OpenOptionsExt as _;
303 options.mode(0o600);
304 }
305 let mut file = options.open(&temp_path)?;
306 file.write_all(format!("{base_sha}\n").as_bytes())?;
307 file.sync_all()?;
308 std::fs::rename(&temp_path, &sha_path)
309 })();
310 if let Err(err) = &result {
311 let _ = std::fs::remove_file(&temp_path);
312 tracing::debug!(
313 path = %sha_path.display(),
314 error = %err,
315 "failed to write reusable audit worktree .sha sidecar; next run will rebuild",
316 );
317 }
318 result
319}
320
321const DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS: u32 = 30;
323
324const SECONDS_PER_DAY: u64 = 86_400;
325
326const AUDIT_CACHE_MAX_AGE_ENV: &str = "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS";
328
329const REUSABLE_LAST_USED_SUFFIX: &str = ".last-used";
331
332const REUSABLE_SHA_SUFFIX: &str = ".sha";
334
335const REUSABLE_LOCK_SUFFIX: &str = ".lock";
337
338const UNREGISTERED_GITDIR_STUB: &str = "gitdir: fallow-audit-unregistered\n";
349
350pub fn reusable_worktree_last_used_path(reusable_path: &Path) -> PathBuf {
355 sidecar_path(reusable_path, REUSABLE_LAST_USED_SUFFIX)
356}
357
358pub fn touch_last_used(reusable_path: &Path) {
366 stamp_last_used(reusable_path, None);
367}
368
369pub fn record_last_used(reusable_path: &Path, owner_root: &Path) {
376 stamp_last_used(reusable_path, Some(owner_root));
377}
378
379fn stamp_last_used(reusable_path: &Path, owner_root: Option<&Path>) {
380 let last_used = reusable_worktree_last_used_path(reusable_path);
381 let result = open_or_create_owned_sidecar(&last_used).and_then(|mut file| {
382 if let Some(owner_root) = owner_root {
383 file.set_len(0)?;
384 file.write_all(format!("{}\n", owner_root.display()).as_bytes())?;
385 }
386 file.set_modified(SystemTime::now())
387 });
388 if let Err(err) = result {
389 tracing::warn!(
390 path = %last_used.display(),
391 error = %err,
392 "failed to touch reusable audit worktree sidecar; staleness signal may not update",
393 );
394 }
395}
396
397fn read_last_used_owner(reusable_path: &Path) -> Option<PathBuf> {
402 const MAX_OWNER_SIDECAR_BYTES: u64 = 4096;
403
404 let sidecar = reusable_worktree_last_used_path(reusable_path);
405 let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
406 if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_OWNER_SIDECAR_BYTES {
407 return None;
408 }
409 let mut contents = String::new();
410 std::fs::File::open(sidecar)
411 .ok()?
412 .take(MAX_OWNER_SIDECAR_BYTES)
413 .read_to_string(&mut contents)
414 .ok()?;
415 let owner = contents.trim();
416 if owner.is_empty() {
417 return None;
418 }
419 Some(PathBuf::from(owner))
420}
421
422fn open_or_create_owned_sidecar(path: &Path) -> std::io::Result<std::fs::File> {
423 match std::fs::symlink_metadata(path) {
424 Ok(metadata) if sidecar_metadata_is_trusted(&metadata) => {
425 std::fs::OpenOptions::new().write(true).open(path)
426 }
427 Ok(_) => Err(std::io::Error::new(
428 std::io::ErrorKind::PermissionDenied,
429 "refusing to open an untrusted audit cache sidecar",
430 )),
431 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
432 let mut options = std::fs::OpenOptions::new();
433 options.create_new(true).write(true);
434 #[cfg(unix)]
435 {
436 use std::os::unix::fs::OpenOptionsExt as _;
437 options.mode(0o600);
438 }
439 options.open(path)
440 }
441 Err(error) => Err(error),
442 }
443}
444
445#[cfg(unix)]
446fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
447 use std::os::unix::fs::MetadataExt as _;
448
449 metadata_is_regular_file(metadata) && metadata.uid() == rustix::process::geteuid().as_raw()
450}
451
452#[cfg(not(unix))]
453fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
454 metadata_is_regular_file(metadata)
455}
456
457#[expect(
458 clippy::filetype_is_file,
459 reason = "security-sensitive sidecars and gitfiles must be regular files, not arbitrary non-directories"
460)]
461fn metadata_is_regular_file(metadata: &std::fs::Metadata) -> bool {
462 metadata.file_type().is_file()
463}
464
465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467pub enum CacheMaxAgeSource {
468 Flag,
469 Env,
470 Config,
471 Default,
472}
473
474impl CacheMaxAgeSource {
475 #[must_use]
476 pub const fn as_str(self) -> &'static str {
477 match self {
478 Self::Flag => "flag",
479 Self::Env => "env",
480 Self::Config => "config",
481 Self::Default => "default",
482 }
483 }
484}
485
486#[derive(Debug, Clone, Copy)]
489pub struct ResolvedCacheMaxAge {
490 pub max_age: Option<Duration>,
491 pub days: u32,
492 pub source: CacheMaxAgeSource,
493}
494
495pub fn resolve_cache_max_age_with_options(
502 root: &Path,
503 config_path: Option<&PathBuf>,
504 allow_remote_extends: bool,
505) -> Option<Duration> {
506 resolve_cache_max_age_with_source(root, config_path, allow_remote_extends, None).max_age
507}
508
509pub fn resolve_cache_max_age_with_source(
513 root: &Path,
514 config_path: Option<&PathBuf>,
515 allow_remote_extends: bool,
516 flag_days: Option<u32>,
517) -> ResolvedCacheMaxAge {
518 if let Some(days) = flag_days {
519 return ResolvedCacheMaxAge {
520 max_age: days_to_duration(days),
521 days,
522 source: CacheMaxAgeSource::Flag,
523 };
524 }
525 if let Ok(raw) = std::env::var(AUDIT_CACHE_MAX_AGE_ENV) {
526 if let Ok(days) = raw.trim().parse::<u32>() {
527 return ResolvedCacheMaxAge {
528 max_age: days_to_duration(days),
529 days,
530 source: CacheMaxAgeSource::Env,
531 };
532 }
533 tracing::warn!(
534 value = %raw,
535 "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS is not a valid u32; falling back to config/default",
536 );
537 }
538 if let Some(days) = load_audit_config(root, config_path, allow_remote_extends)
539 .and_then(|c| c.cache_max_age_days)
540 {
541 return ResolvedCacheMaxAge {
542 max_age: days_to_duration(days),
543 days,
544 source: CacheMaxAgeSource::Config,
545 };
546 }
547 ResolvedCacheMaxAge {
548 max_age: days_to_duration(DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS),
549 days: DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS,
550 source: CacheMaxAgeSource::Default,
551 }
552}
553
554pub fn days_to_duration(days: u32) -> Option<Duration> {
555 if days == 0 {
556 return None;
557 }
558 Some(Duration::from_secs(u64::from(days) * SECONDS_PER_DAY))
559}
560
561fn load_audit_config(
565 root: &Path,
566 config_path: Option<&PathBuf>,
567 allow_remote_extends: bool,
568) -> Option<fallow_config::AuditConfig> {
569 let options = fallow_config::ConfigLoadOptions {
570 allow_remote_extends,
571 };
572 if let Some(path) = config_path {
573 return fallow_config::FallowConfig::load_with_options(path, options)
574 .ok()
575 .map(|config| config.audit);
576 }
577 fallow_config::FallowConfig::find_and_load_with_options(root, options)
578 .ok()
579 .flatten()
580 .map(|(config, _path)| config.audit)
581}
582
583pub fn sweep_old_reusable_caches(repo_root: &Path, max_age: Option<Duration>, quiet: bool) {
618 sweep_old_reusable_caches_in(repo_root, max_age, quiet, &std::env::temp_dir());
619}
620
621pub fn sweep_old_reusable_caches_in(
627 repo_root: &Path,
628 max_age: Option<Duration>,
629 quiet: bool,
630 scan_root: &Path,
631) {
632 let report = sweep_reusable_caches_with_report(
633 repo_root,
634 max_age,
635 scan_root,
636 SweepMode::Apply,
637 SweepSizes::Skip,
638 );
639 log_sweep_entries(&report, SweepMode::Apply, max_age);
640 let removed = report.removed;
641 if removed == 0 {
642 return;
643 }
644 tracing::info!(
645 count = removed,
646 "reclaimed stale audit base-snapshot caches",
647 );
648 if !quiet {
649 let s = plural(removed as usize);
650 let _ = writeln!(
651 std::io::stderr(),
652 "fallow: reclaimed {removed} stale base-snapshot cache{s}",
653 );
654 }
655}
656
657#[derive(Debug, Clone, Copy, PartialEq, Eq)]
659pub enum SweepMode {
660 Apply,
662 DryRun,
666}
667
668impl SweepMode {
669 #[must_use]
670 pub const fn as_str(self) -> &'static str {
671 match self {
672 Self::Apply => "apply",
673 Self::DryRun => "dry-run",
674 }
675 }
676}
677
678#[derive(Debug, Clone, Copy, PartialEq, Eq)]
682pub enum SweepSizes {
683 Skip,
684 Measure,
685}
686
687#[derive(Debug, Clone, Copy, PartialEq, Eq)]
689pub enum SweepPass {
690 Owned,
692 Legacy,
694 Foreign,
696}
697
698impl SweepPass {
699 #[must_use]
700 pub const fn as_str(self) -> &'static str {
701 match self {
702 Self::Owned => "owned",
703 Self::Legacy => "legacy",
704 Self::Foreign => "foreign",
705 }
706 }
707}
708
709#[derive(Debug, Clone, Copy, PartialEq, Eq)]
712pub enum SweepDecision {
713 Removed,
714 Kept,
715 Skipped,
716 Failed,
717}
718
719impl SweepDecision {
720 #[must_use]
721 pub const fn as_str(self) -> &'static str {
722 match self {
723 Self::Removed => "removed",
724 Self::Kept => "kept",
725 Self::Skipped => "skipped",
726 Self::Failed => "failed",
727 }
728 }
729}
730
731#[derive(Debug, Clone, Copy, PartialEq, Eq)]
733pub enum SweepDisposition {
734 ReclaimedOrphan,
736 ReclaimedAged,
738 ReclaimedOwnerMissing,
743 ReclaimedLegacyRegistered,
747 KeptLegacyDeregistered,
752 KeptFresh,
754 KeptOwnerLive,
757 KeptOwnerUnverifiable(std::io::ErrorKind),
760 KeptAgeGcDisabled,
763 KeptGraceSeeded,
766 KeptLockOnly,
769 KeptRecreated,
772 KeptNotOwned,
775 SkippedLocked,
777 RemoveFailed,
779}
780
781impl SweepDisposition {
782 #[must_use]
783 pub const fn decision(self) -> SweepDecision {
784 match self {
785 Self::ReclaimedOrphan
786 | Self::ReclaimedAged
787 | Self::ReclaimedOwnerMissing
788 | Self::ReclaimedLegacyRegistered => SweepDecision::Removed,
789 Self::KeptLegacyDeregistered
790 | Self::KeptFresh
791 | Self::KeptOwnerLive
792 | Self::KeptOwnerUnverifiable(_)
793 | Self::KeptAgeGcDisabled
794 | Self::KeptGraceSeeded
795 | Self::KeptLockOnly
796 | Self::KeptRecreated
797 | Self::KeptNotOwned => SweepDecision::Kept,
798 Self::SkippedLocked => SweepDecision::Skipped,
799 Self::RemoveFailed => SweepDecision::Failed,
800 }
801 }
802
803 #[must_use]
806 pub const fn reason(self) -> &'static str {
807 match self {
808 Self::ReclaimedOrphan => "orphaned-sidecars",
809 Self::ReclaimedAged => "aged-out",
810 Self::ReclaimedOwnerMissing => "owner-missing",
811 Self::ReclaimedLegacyRegistered => "legacy-registered",
812 Self::KeptLegacyDeregistered => "legacy-deregistered",
813 Self::KeptFresh => "fresh",
814 Self::KeptOwnerLive => "owner-live",
815 Self::KeptOwnerUnverifiable(_) => "owner-unverifiable",
816 Self::KeptAgeGcDisabled => "age-gc-disabled",
817 Self::KeptGraceSeeded => "grace-seeded",
818 Self::KeptLockOnly => "lock-only",
819 Self::KeptRecreated => "recreated",
820 Self::KeptNotOwned => "not-owned",
821 Self::SkippedLocked => "lock-contention",
822 Self::RemoveFailed => "remove-failed",
823 }
824 }
825
826 const fn counts_toward_summary(self) -> bool {
829 matches!(
830 self,
831 Self::ReclaimedOrphan | Self::ReclaimedAged | Self::ReclaimedOwnerMissing
832 )
833 }
834}
835
836#[derive(Debug, Clone)]
838pub struct SweepEntry {
839 pub path: PathBuf,
840 pub pass: SweepPass,
841 pub disposition: SweepDisposition,
842 pub age_days: Option<u64>,
844 pub owner_root: Option<PathBuf>,
846 pub size_bytes: Option<u64>,
849}
850
851#[derive(Debug, Default)]
855pub struct SweepReport {
856 pub entries: Vec<SweepEntry>,
857 pub removed: u32,
858}
859
860impl SweepReport {
861 fn push(&mut self, entry: SweepEntry) {
862 if entry.disposition.counts_toward_summary() {
863 self.removed += 1;
864 }
865 self.entries.push(entry);
866 }
867}
868
869const GC_LOCK_CONTEXT: &str = "gc sweep skips the entry";
870
871pub fn log_sweep_entries(report: &SweepReport, mode: SweepMode, max_age: Option<Duration>) {
875 let threshold_days = max_age.map(|age| age.as_secs() / SECONDS_PER_DAY);
876 for entry in &report.entries {
877 let owner_probe_error = match entry.disposition {
878 SweepDisposition::KeptOwnerUnverifiable(kind) => Some(kind),
879 _ => None,
880 };
881 tracing::debug!(
882 path = %entry.path.display(),
883 pass = entry.pass.as_str(),
884 mode = mode.as_str(),
885 decision = entry.disposition.decision().as_str(),
886 reason = entry.disposition.reason(),
887 age_days = entry.age_days,
888 threshold_days,
889 owner_root = entry
890 .owner_root
891 .as_ref()
892 .map(|owner| tracing::field::display(owner.display())),
893 owner_probe_error = owner_probe_error.map(tracing::field::debug),
894 "audit cache sweep considered entry",
895 );
896 }
897}
898
899pub fn sweep_reusable_caches_with_report(
905 repo_root: &Path,
906 max_age: Option<Duration>,
907 scan_root: &Path,
908 mode: SweepMode,
909 sizes: SweepSizes,
910) -> SweepReport {
911 let now = SystemTime::now();
912 let mut report = SweepReport::default();
913
914 legacy_registered_pass(repo_root, mode, sizes, now, &mut report);
920
921 let owned_path = reusable_audit_worktree_path(repo_root);
925 let mut paths = vec![owned_path.clone()];
926 paths.extend(scan_legacy_reusable_cache_paths(repo_root, scan_root));
927 paths.sort();
928 paths.dedup();
929
930 let scoped: FxHashSet<&PathBuf> = paths.iter().collect();
937 let foreign: Vec<PathBuf> = scan_all_reusable_cache_paths(scan_root)
938 .into_iter()
939 .filter(|path| !scoped.contains(path))
940 .collect();
941 drop(scoped);
942
943 let mut size_map = measure_entry_sizes(sizes, paths.iter().chain(foreign.iter()));
946
947 for path in &paths {
948 if !cache_entry_has_presence(path) {
954 continue;
955 }
956 let pass = if *path == owned_path {
957 SweepPass::Owned
958 } else {
959 SweepPass::Legacy
960 };
961 let (age_days, owner_root) = entry_probe_metadata(path, now);
962 let disposition = match mode {
963 SweepMode::Apply => reclaim_reusable_cache_entry(repo_root, path, max_age, now),
964 SweepMode::DryRun => classify_entry_dry_run(path, max_age, now, OwnerGate::Off),
965 };
966 report.push(SweepEntry {
967 path: path.clone(),
968 pass,
969 disposition,
970 age_days,
971 owner_root,
972 size_bytes: size_map.remove(path).flatten(),
973 });
974 }
975 for path in &foreign {
976 let (age_days, owner_root) = entry_probe_metadata(path, now);
977 let disposition = match mode {
978 SweepMode::Apply => reclaim_foreign_cache_entry(repo_root, path, max_age, now),
979 SweepMode::DryRun => classify_entry_dry_run(path, max_age, now, OwnerGate::On),
980 };
981 report.push(SweepEntry {
982 path: path.clone(),
983 pass: SweepPass::Foreign,
984 disposition,
985 age_days,
986 owner_root,
987 size_bytes: size_map.remove(path).flatten(),
988 });
989 }
990 report
991}
992
993fn entry_probe_metadata(path: &Path, now: SystemTime) -> (Option<u64>, Option<PathBuf>) {
996 let age_days = last_used_mtime(path)
997 .and_then(|mtime| now.duration_since(mtime).ok())
998 .map(|age| age.as_secs() / SECONDS_PER_DAY);
999 (age_days, read_last_used_owner(path))
1000}
1001
1002fn legacy_registered_pass(
1012 repo_root: &Path,
1013 mode: SweepMode,
1014 sizes: SweepSizes,
1015 now: SystemTime,
1016 report: &mut SweepReport,
1017) {
1018 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1019 return;
1020 };
1021 let candidates: Vec<PathBuf> = worktrees
1022 .into_iter()
1023 .filter(|path| is_reusable_audit_worktree_path(path))
1024 .collect();
1025 let mut size_map = measure_entry_sizes(sizes, candidates.iter());
1026 let owned_path = reusable_audit_worktree_path(repo_root);
1027 let mut deregistered = false;
1028 for path in candidates {
1029 let pass = if paths_equal(&path, &owned_path) {
1030 SweepPass::Owned
1031 } else {
1032 SweepPass::Legacy
1033 };
1034 let (age_days, owner_root) = entry_probe_metadata(&path, now);
1035 let size_bytes = size_map.remove(&path).flatten();
1036 let is_current_path = pass == SweepPass::Owned;
1037 let disposition = match mode {
1038 SweepMode::DryRun => {
1039 if !audit_worktree_is_registered(repo_root, &path) {
1040 continue;
1041 }
1042 if is_current_path {
1043 SweepDisposition::KeptLegacyDeregistered
1044 } else {
1045 SweepDisposition::ReclaimedLegacyRegistered
1046 }
1047 }
1048 SweepMode::Apply => {
1049 let Some(_lock) = ReusableWorktreeLock::try_acquire(
1050 &path,
1051 "legacy deregistration skips the entry",
1052 ) else {
1053 report.push(SweepEntry {
1054 path,
1055 pass,
1056 disposition: SweepDisposition::SkippedLocked,
1057 age_days,
1058 owner_root,
1059 size_bytes,
1060 });
1061 continue;
1062 };
1063 if !audit_worktree_is_registered(repo_root, &path) {
1064 continue;
1065 }
1066 let head = is_current_path
1067 .then(|| legacy_reusable_sha(&path))
1068 .flatten();
1069 if unregister_worktree_checked(repo_root, &path).is_err() {
1070 report.push(SweepEntry {
1071 path,
1072 pass,
1073 disposition: SweepDisposition::RemoveFailed,
1074 age_days,
1075 owner_root,
1076 size_bytes,
1077 });
1078 continue;
1079 }
1080 let disposition = if is_current_path {
1081 if let Some(head) = head {
1082 let _ = write_reusable_sha(&path, &head);
1083 }
1084 SweepDisposition::KeptLegacyDeregistered
1085 } else if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
1086 tracing::warn!(
1087 path = %path.display(),
1088 error = %error,
1089 "failed to remove released SHA-keyed audit cache",
1090 );
1091 SweepDisposition::RemoveFailed
1092 } else {
1093 SweepDisposition::ReclaimedLegacyRegistered
1094 };
1095 deregistered = true;
1096 disposition
1097 }
1098 };
1099 report.push(SweepEntry {
1100 path,
1101 pass,
1102 disposition,
1103 age_days,
1104 owner_root,
1105 size_bytes,
1106 });
1107 }
1108 if deregistered {
1109 let mut command = Command::new("git");
1110 command
1111 .args(["worktree", "prune", "--expire=now"])
1112 .current_dir(repo_root);
1113 clear_ambient_git_env(&mut command);
1114 let _ = command.output();
1115 }
1116}
1117
1118fn legacy_reusable_sha(path: &Path) -> Option<String> {
1122 if reusable_worktree_sha_path(path).exists()
1123 || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
1124 {
1125 return None;
1126 }
1127 git_rev_parse(path, "HEAD")
1128}
1129
1130fn scan_legacy_reusable_cache_paths(repo_root: &Path, scan_root: &Path) -> Vec<PathBuf> {
1135 let Some(prefix) = legacy_reusable_cache_repo_prefix(repo_root) else {
1136 return Vec::new();
1137 };
1138 scan_cache_paths_with_hex_suffix(&prefix, scan_root)
1139}
1140
1141fn scan_root_owned_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
1142 let Some(prefix) = root_owned_cache_repo_prefix(repo_root) else {
1143 return Vec::new();
1144 };
1145 scan_cache_paths_with_hex_suffix(&prefix, &std::env::temp_dir())
1146}
1147
1148fn scan_all_reusable_cache_paths(scan_root: &Path) -> Vec<PathBuf> {
1154 const GLOBAL_CACHE_PREFIX: &str = "fallow-audit-base-cache-";
1155
1156 let Ok(entries) = std::fs::read_dir(scan_root) else {
1157 return Vec::new();
1158 };
1159 let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
1160 let mut paths = Vec::new();
1161 for entry in entries.flatten() {
1162 let name = entry.file_name();
1163 let Some(name) = name.to_str() else {
1164 continue;
1165 };
1166 let cache_name = strip_cache_sidecar_suffix(name);
1167 let Some(hash_suffix) = cache_name.strip_prefix(GLOBAL_CACHE_PREFIX) else {
1168 continue;
1169 };
1170 if !cache_hash_suffix_is_valid(hash_suffix) {
1171 continue;
1172 }
1173 let path = scan_root.join(cache_name);
1174 if seen.insert(path.clone()) {
1175 paths.push(path);
1176 }
1177 }
1178 paths
1179}
1180
1181fn cache_hash_suffix_is_valid(suffix: &str) -> bool {
1182 fn is_hex16(part: &str) -> bool {
1183 part.len() == 16 && part.bytes().all(|byte| byte.is_ascii_hexdigit())
1184 }
1185 if let Some((repo, root)) = suffix.split_once("-root-") {
1186 return is_hex16(repo) && is_hex16(root);
1187 }
1188 suffix
1189 .split_once('-')
1190 .is_some_and(|(repo, sha)| is_hex16(repo) && is_hex16(sha))
1191}
1192
1193fn scan_cache_paths_with_hex_suffix(prefix: &str, scan_root: &Path) -> Vec<PathBuf> {
1194 let Ok(entries) = std::fs::read_dir(scan_root) else {
1195 return Vec::new();
1196 };
1197 let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
1198 let mut paths = Vec::new();
1199 for entry in entries.flatten() {
1200 let name = entry.file_name();
1201 let Some(name) = name.to_str() else {
1202 continue;
1203 };
1204 let cache_name = strip_cache_sidecar_suffix(name);
1205 let Some(hash_suffix) = cache_name.strip_prefix(prefix) else {
1206 continue;
1207 };
1208 if hash_suffix.len() != 16 || !hash_suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1209 continue;
1210 }
1211 let path = scan_root.join(cache_name);
1212 if seen.insert(path.clone()) {
1213 paths.push(path);
1214 }
1215 }
1216 paths
1217}
1218
1219fn strip_cache_sidecar_suffix(name: &str) -> &str {
1223 for suffix in [
1224 REUSABLE_LAST_USED_SUFFIX,
1225 REUSABLE_SHA_SUFFIX,
1226 REUSABLE_LOCK_SUFFIX,
1227 ] {
1228 if let Some(stripped) = name.strip_suffix(suffix) {
1229 return stripped;
1230 }
1231 }
1232 name
1233}
1234
1235fn reclaim_reusable_cache_entry(
1237 repo_root: &Path,
1238 path: &Path,
1239 max_age: Option<Duration>,
1240 now: SystemTime,
1241) -> SweepDisposition {
1242 if !path.exists() {
1254 return reclaim_orphan_cache_entry(repo_root, path);
1255 }
1256 let Some(max_age) = max_age else {
1257 return SweepDisposition::KeptAgeGcDisabled;
1258 };
1259 reclaim_aged_cache_entry(repo_root, path, max_age, now)
1260}
1261
1262pub fn reclaim_orphan_cache_entry(repo_root: &Path, path: &Path) -> SweepDisposition {
1268 let Some(_lock) = ReusableWorktreeLock::try_acquire(path, GC_LOCK_CONTEXT) else {
1269 return SweepDisposition::SkippedLocked;
1270 };
1271 if path.exists() {
1274 return SweepDisposition::KeptRecreated;
1275 }
1276 match remove_cache_entry_for_sweep(repo_root, path) {
1277 Ok(CacheRemovalOutcome::Removed) => SweepDisposition::ReclaimedOrphan,
1278 Ok(CacheRemovalOutcome::NothingLeft) => SweepDisposition::KeptLockOnly,
1279 Ok(CacheRemovalOutcome::NotOwned) => SweepDisposition::KeptNotOwned,
1280 Err(_) => SweepDisposition::RemoveFailed,
1281 }
1282}
1283
1284fn reclaim_aged_cache_entry(
1290 repo_root: &Path,
1291 path: &Path,
1292 max_age: Duration,
1293 now: SystemTime,
1294) -> SweepDisposition {
1295 let Some(mtime) = last_used_mtime(path) else {
1296 record_last_used(path, repo_root);
1297 return SweepDisposition::KeptGraceSeeded;
1298 };
1299 remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
1300}
1301
1302fn reclaim_foreign_cache_entry(
1317 repo_root: &Path,
1318 path: &Path,
1319 max_age: Option<Duration>,
1320 now: SystemTime,
1321) -> SweepDisposition {
1322 if !path.exists() {
1325 return reclaim_orphan_cache_entry(repo_root, path);
1326 }
1327 let mut owner_missing = false;
1328 if let Some(owner) = read_last_used_owner(path) {
1329 match probe_owner_liveness(&owner) {
1330 OwnerLiveness::Live => return SweepDisposition::KeptOwnerLive,
1331 OwnerLiveness::Unverifiable(kind) => {
1332 return SweepDisposition::KeptOwnerUnverifiable(kind);
1333 }
1334 OwnerLiveness::Dead => owner_missing = true,
1335 }
1336 }
1337 let Some(max_age) = max_age else {
1338 if owner_missing {
1339 return remove_cache_entry_under_lock(
1340 repo_root,
1341 path,
1342 SweepDisposition::ReclaimedOwnerMissing,
1343 );
1344 }
1345 return SweepDisposition::KeptAgeGcDisabled;
1346 };
1347 let Some(mtime) = last_used_mtime(path) else {
1348 touch_last_used(path);
1349 return SweepDisposition::KeptGraceSeeded;
1350 };
1351 remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
1352}
1353
1354enum OwnerLiveness {
1355 Live,
1356 Dead,
1357 Unverifiable(std::io::ErrorKind),
1358}
1359
1360fn probe_owner_liveness(owner: &Path) -> OwnerLiveness {
1370 match std::fs::metadata(owner) {
1371 Ok(_) => OwnerLiveness::Live,
1372 Err(error) if error.kind() == std::io::ErrorKind::NotFound => OwnerLiveness::Dead,
1373 Err(error) => OwnerLiveness::Unverifiable(error.kind()),
1374 }
1375}
1376
1377#[derive(Clone, Copy, PartialEq, Eq)]
1380enum OwnerGate {
1381 On,
1382 Off,
1383}
1384
1385fn classify_entry_dry_run(
1391 path: &Path,
1392 max_age: Option<Duration>,
1393 now: SystemTime,
1394 owner_gate: OwnerGate,
1395) -> SweepDisposition {
1396 if !path.exists() {
1397 if !reusable_cache_entry_exists(path) {
1398 return SweepDisposition::KeptLockOnly;
1399 }
1400 return classify_would_remove(path, SweepDisposition::ReclaimedOrphan);
1401 }
1402 let mut owner_missing = false;
1403 if owner_gate == OwnerGate::On
1404 && let Some(owner) = read_last_used_owner(path)
1405 {
1406 match probe_owner_liveness(&owner) {
1407 OwnerLiveness::Live => return SweepDisposition::KeptOwnerLive,
1408 OwnerLiveness::Unverifiable(kind) => {
1409 return SweepDisposition::KeptOwnerUnverifiable(kind);
1410 }
1411 OwnerLiveness::Dead => owner_missing = true,
1412 }
1413 }
1414 let Some(max_age) = max_age else {
1415 if owner_missing {
1416 return classify_would_remove(path, SweepDisposition::ReclaimedOwnerMissing);
1417 }
1418 return SweepDisposition::KeptAgeGcDisabled;
1419 };
1420 let Some(mtime) = last_used_mtime(path) else {
1421 return SweepDisposition::KeptGraceSeeded;
1422 };
1423 let Ok(age) = now.duration_since(mtime) else {
1424 return SweepDisposition::KeptFresh;
1425 };
1426 if age < max_age {
1427 return SweepDisposition::KeptFresh;
1428 }
1429 classify_would_remove(path, SweepDisposition::ReclaimedAged)
1430}
1431
1432fn classify_would_remove(path: &Path, removed: SweepDisposition) -> SweepDisposition {
1436 match cache_entry_ownership(path) {
1437 Ok(CacheEntryOwnership::Owned) => removed,
1438 Ok(CacheEntryOwnership::Unowned(_)) => SweepDisposition::KeptNotOwned,
1439 Err(_) => SweepDisposition::RemoveFailed,
1440 }
1441}
1442
1443fn last_used_mtime(path: &Path) -> Option<SystemTime> {
1444 std::fs::metadata(reusable_worktree_last_used_path(path))
1445 .ok()
1446 .and_then(|metadata| metadata.modified().ok())
1447}
1448
1449fn remove_entry_past_max_age(
1450 repo_root: &Path,
1451 path: &Path,
1452 max_age: Duration,
1453 now: SystemTime,
1454 mtime: SystemTime,
1455) -> SweepDisposition {
1456 let Ok(age) = now.duration_since(mtime) else {
1457 return SweepDisposition::KeptFresh;
1458 };
1459 if age < max_age {
1460 return SweepDisposition::KeptFresh;
1461 }
1462 remove_cache_entry_under_lock(repo_root, path, SweepDisposition::ReclaimedAged)
1463}
1464
1465fn remove_cache_entry_under_lock(
1467 repo_root: &Path,
1468 path: &Path,
1469 removed: SweepDisposition,
1470) -> SweepDisposition {
1471 let Some(_lock) = ReusableWorktreeLock::try_acquire(path, GC_LOCK_CONTEXT) else {
1472 return SweepDisposition::SkippedLocked;
1473 };
1474 match remove_cache_entry_for_sweep(repo_root, path) {
1475 Ok(CacheRemovalOutcome::Removed) => removed,
1476 Ok(CacheRemovalOutcome::NothingLeft) => SweepDisposition::KeptLockOnly,
1477 Ok(CacheRemovalOutcome::NotOwned) => SweepDisposition::KeptNotOwned,
1478 Err(err) => {
1479 tracing::warn!(
1480 path = %path.display(),
1481 error = %err,
1482 "failed to remove stale reusable audit worktree entry; entry may leak",
1483 );
1484 SweepDisposition::RemoveFailed
1485 }
1486 }
1487}
1488
1489enum CacheRemovalOutcome {
1490 Removed,
1491 NothingLeft,
1493 NotOwned,
1495}
1496
1497fn remove_cache_entry_for_sweep(
1501 repo_root: &Path,
1502 path: &Path,
1503) -> std::io::Result<CacheRemovalOutcome> {
1504 if matches!(
1505 cache_entry_ownership(path)?,
1506 CacheEntryOwnership::Unowned(_)
1507 ) {
1508 return Ok(CacheRemovalOutcome::NotOwned);
1509 }
1510 remove_reusable_cache_entry_locked(repo_root, path).map(|removed| {
1511 if removed {
1512 CacheRemovalOutcome::Removed
1513 } else {
1514 CacheRemovalOutcome::NothingLeft
1515 }
1516 })
1517}
1518
1519fn directory_size_bytes(root: &Path) -> Option<u64> {
1528 let root_entries = std::fs::read_dir(root).ok()?;
1529 let mut total: u64 = 0;
1530 let mut stack = vec![root_entries];
1531 while let Some(entries) = stack.pop() {
1532 for entry in entries.flatten() {
1533 let Ok(metadata) = entry.metadata() else {
1534 continue;
1535 };
1536 if metadata.is_dir() {
1537 if let Ok(child) = std::fs::read_dir(entry.path()) {
1538 stack.push(child);
1539 }
1540 } else if metadata_is_regular_file(&metadata) {
1541 total = total.saturating_add(metadata.len());
1542 }
1543 }
1544 }
1545 Some(total)
1546}
1547
1548fn measure_entry_sizes<'a>(
1551 sizes: SweepSizes,
1552 paths: impl Iterator<Item = &'a PathBuf>,
1553) -> FxHashMap<PathBuf, Option<u64>> {
1554 use rayon::iter::{IntoParallelRefIterator as _, ParallelIterator as _};
1555
1556 if sizes == SweepSizes::Skip {
1557 return FxHashMap::default();
1558 }
1559 let paths: Vec<&PathBuf> = paths.collect();
1560 paths
1561 .par_iter()
1562 .map(|path| ((*path).clone(), directory_size_bytes(path)))
1563 .collect()
1564}
1565
1566pub fn canonical_root_hash(root: &Path) -> u64 {
1567 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1568 xxh3_64(&path_identity_bytes(&canonical_root))
1569}
1570
1571#[cfg(unix)]
1572fn path_identity_bytes(path: &Path) -> Vec<u8> {
1573 use std::os::unix::ffi::OsStrExt as _;
1574
1575 path.as_os_str().as_bytes().to_vec()
1576}
1577
1578#[cfg(windows)]
1579fn path_identity_bytes(path: &Path) -> Vec<u8> {
1580 use std::os::windows::ffi::OsStrExt as _;
1581
1582 path.as_os_str()
1583 .encode_wide()
1584 .flat_map(u16::to_le_bytes)
1585 .collect()
1586}
1587
1588#[cfg(not(any(unix, windows)))]
1589fn path_identity_bytes(path: &Path) -> Vec<u8> {
1590 path.to_string_lossy().as_bytes().to_vec()
1591}
1592
1593pub fn reusable_audit_worktree_path(requested_root: &Path) -> PathBuf {
1594 let root_hash = canonical_root_hash(requested_root);
1595 let repo_hash = git_toplevel(requested_root)
1596 .as_deref()
1597 .map_or(root_hash, canonical_root_hash);
1598 std::env::temp_dir().join(format!(
1599 "fallow-audit-base-cache-{repo_hash:016x}-root-{root_hash:016x}"
1600 ))
1601}
1602
1603fn root_owned_cache_repo_prefix(requested_root: &Path) -> Option<String> {
1604 let git_root = git_toplevel(requested_root)?;
1605 let repo_hash = canonical_root_hash(&git_root);
1606 Some(format!("fallow-audit-base-cache-{repo_hash:016x}-root-"))
1607}
1608
1609fn legacy_reusable_cache_repo_prefix(requested_root: &Path) -> Option<String> {
1610 let git_root = git_toplevel(requested_root)?;
1611 let repo_hash = canonical_root_hash(&git_root);
1612 Some(format!("fallow-audit-base-cache-{repo_hash:016x}-"))
1613}
1614
1615#[cfg(test)]
1616pub fn legacy_reusable_audit_worktree_path(
1617 requested_root: &Path,
1618 base_sha: &str,
1619) -> Option<PathBuf> {
1620 let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
1621 Some(std::env::temp_dir().join(format!(
1622 "{}{sha_prefix}",
1623 legacy_reusable_cache_repo_prefix(requested_root)?
1624 )))
1625}
1626
1627fn reusable_audit_worktree_is_ready(path: &Path, base_sha: &str) -> bool {
1638 if !reusable_cache_directory_is_trusted(path) {
1639 return false;
1640 }
1641 let recorded = read_reusable_sha(path);
1642 if recorded.as_deref() != Some(base_sha) {
1643 return false;
1644 }
1645 repair_unregistered_git_stub(path)
1646}
1647
1648fn read_reusable_sha(path: &Path) -> Option<String> {
1649 const MAX_SHA_SIDECAR_BYTES: u64 = 129;
1650
1651 let sidecar = reusable_worktree_sha_path(path);
1652 let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
1653 if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_SHA_SIDECAR_BYTES {
1654 return None;
1655 }
1656 let mut contents = String::new();
1657 std::fs::File::open(sidecar)
1658 .ok()?
1659 .take(MAX_SHA_SIDECAR_BYTES)
1660 .read_to_string(&mut contents)
1661 .ok()?;
1662 Some(contents.trim().to_owned())
1663}
1664
1665#[cfg(unix)]
1666fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
1667 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1668
1669 let Ok(metadata) = std::fs::symlink_metadata(path) else {
1670 return false;
1671 };
1672 metadata.file_type().is_dir()
1673 && metadata.uid() == rustix::process::geteuid().as_raw()
1674 && metadata.permissions().mode().trailing_zeros() >= 6
1675}
1676
1677#[cfg(not(unix))]
1678fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
1679 std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
1680}
1681
1682fn try_migrate_registered_current_cache(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
1688 if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
1689 return false;
1690 }
1691 let head_matches = git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha);
1692 if !head_matches || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
1693 {
1694 return false;
1695 }
1696 if unregister_worktree_checked(repo_root, path).is_err() {
1697 return false;
1698 }
1699 write_reusable_sha(path, base_sha).is_ok()
1700}
1701
1702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1709pub struct AuditCacheRemovalReport {
1710 pub found: usize,
1711 pub removed: usize,
1712 pub skipped: usize,
1713 pub dry_run: bool,
1714}
1715
1716pub fn remove_reusable_audit_caches(
1717 requested_root: &Path,
1718 dry_run: bool,
1719) -> std::io::Result<AuditCacheRemovalReport> {
1720 let mut paths = vec![reusable_audit_worktree_path(requested_root)];
1721 paths.extend(scan_legacy_reusable_cache_paths(
1722 requested_root,
1723 &std::env::temp_dir(),
1724 ));
1725 if git_toplevel(requested_root).is_some_and(|root| paths_equal(&root, requested_root)) {
1726 paths.extend(scan_root_owned_cache_paths(requested_root));
1727 }
1728 paths.sort();
1729 paths.dedup();
1730
1731 let mut report = AuditCacheRemovalReport {
1732 found: 0,
1733 removed: 0,
1734 skipped: 0,
1735 dry_run,
1736 };
1737 for path in paths {
1738 if !reusable_cache_entry_exists(&path) {
1739 continue;
1740 }
1741 report.found += 1;
1742 if dry_run {
1743 continue;
1748 }
1749 let Some(_lock) =
1750 ReusableWorktreeLock::try_acquire(&path, "cache removal reports the entry as skipped")
1751 else {
1752 report.skipped += 1;
1753 continue;
1754 };
1755 if remove_reusable_cache_entry_locked(requested_root, &path)? {
1756 report.removed += 1;
1757 }
1758 }
1759 Ok(report)
1760}
1761
1762fn reusable_cache_entry_exists(path: &Path) -> bool {
1763 path_entry_exists(path)
1764 || path_entry_exists(&reusable_worktree_sha_path(path))
1765 || path_entry_exists(&reusable_worktree_last_used_path(path))
1766}
1767
1768fn cache_entry_has_presence(path: &Path) -> bool {
1772 reusable_cache_entry_exists(path) || path_entry_exists(&reusable_worktree_lock_path(path))
1773}
1774
1775fn path_entry_exists(path: &Path) -> bool {
1776 std::fs::symlink_metadata(path).is_ok()
1777}
1778
1779fn remove_reusable_cache_entry_locked(repo_root: &Path, path: &Path) -> std::io::Result<bool> {
1782 let existed = reusable_cache_entry_exists(path);
1783 ensure_cache_entry_is_owned(path)?;
1784 if trusted_worktree_admin_dir(repo_root, path).is_some() {
1785 unregister_worktree_checked(repo_root, path)?;
1786 }
1787 remove_dir_if_exists(path)?;
1788 remove_file_if_exists(&reusable_worktree_sha_path(path))?;
1789 remove_file_if_exists(&reusable_worktree_last_used_path(path))?;
1790 Ok(existed)
1791}
1792
1793enum CacheEntryOwnership {
1794 Owned,
1795 #[cfg_attr(
1796 not(unix),
1797 expect(dead_code, reason = "only the Unix uid probe constructs this variant")
1798 )]
1799 Unowned(PathBuf),
1800}
1801
1802#[cfg(unix)]
1803fn cache_entry_ownership(path: &Path) -> std::io::Result<CacheEntryOwnership> {
1804 use std::os::unix::fs::MetadataExt as _;
1805
1806 let effective_uid = rustix::process::geteuid().as_raw();
1807 for entry in [
1808 path.to_path_buf(),
1809 reusable_worktree_sha_path(path),
1810 reusable_worktree_last_used_path(path),
1811 ] {
1812 let metadata = match std::fs::symlink_metadata(&entry) {
1813 Ok(metadata) => metadata,
1814 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1815 Err(error) => return Err(error),
1816 };
1817 if metadata.uid() != effective_uid {
1818 return Ok(CacheEntryOwnership::Unowned(entry));
1819 }
1820 }
1821 Ok(CacheEntryOwnership::Owned)
1822}
1823
1824#[cfg(not(unix))]
1825#[expect(
1826 clippy::unnecessary_wraps,
1827 reason = "shared cross-platform signature; the Unix ownership check is fallible, non-Unix has no POSIX owner to verify"
1828)]
1829fn cache_entry_ownership(_path: &Path) -> std::io::Result<CacheEntryOwnership> {
1830 Ok(CacheEntryOwnership::Owned)
1831}
1832
1833fn ensure_cache_entry_is_owned(path: &Path) -> std::io::Result<()> {
1834 match cache_entry_ownership(path)? {
1835 CacheEntryOwnership::Owned => Ok(()),
1836 CacheEntryOwnership::Unowned(entry) => Err(std::io::Error::new(
1837 std::io::ErrorKind::PermissionDenied,
1838 format!(
1839 "refusing to remove unowned audit cache entry `{}`",
1840 entry.display()
1841 ),
1842 )),
1843 }
1844}
1845
1846fn remove_dir_if_exists(path: &Path) -> std::io::Result<()> {
1847 match std::fs::remove_dir_all(path) {
1848 Ok(()) => Ok(()),
1849 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1850 Err(err) => Err(err),
1851 }
1852}
1853
1854fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
1855 match std::fs::remove_file(path) {
1856 Ok(()) => Ok(()),
1857 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1858 Err(err) => Err(err),
1859 }
1860}
1861
1862pub fn unregister_worktree(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1872 unregister_worktree_checked(repo_root, path)
1873}
1874
1875fn unregister_worktree_checked(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1876 if !path.exists() {
1877 return Ok(());
1878 }
1879 let gitfile = path.join(".git");
1880 let metadata = std::fs::symlink_metadata(&gitfile)?;
1881 if !metadata_is_regular_file(&metadata) {
1882 return Err(std::io::Error::new(
1883 std::io::ErrorKind::InvalidData,
1884 "refusing to deregister through a non-file audit worktree .git entry",
1885 ));
1886 }
1887 let contents = std::fs::read_to_string(&gitfile)?;
1888 if contents == UNREGISTERED_GITDIR_STUB {
1889 return Ok(());
1890 }
1891 let Some(admin_dir) = trusted_worktree_admin_dir(repo_root, path) else {
1892 return Err(std::io::Error::new(
1893 std::io::ErrorKind::InvalidData,
1894 "refusing to deregister an unverified audit worktree admin entry",
1895 ));
1896 };
1897 remove_dir_if_exists(&admin_dir)?;
1898 write_git_stub_safely(&gitfile)
1899}
1900
1901fn repair_unregistered_git_stub(path: &Path) -> bool {
1906 let gitfile = path.join(".git");
1907 let Ok(metadata) = std::fs::symlink_metadata(&gitfile) else {
1908 return write_git_stub_safely(&gitfile).is_ok();
1909 };
1910 if !metadata_is_regular_file(&metadata) {
1911 return false;
1912 }
1913 std::fs::read_to_string(&gitfile).is_ok_and(|contents| contents == UNREGISTERED_GITDIR_STUB)
1914}
1915
1916fn write_git_stub_safely(gitfile: &Path) -> std::io::Result<()> {
1917 let mut options = std::fs::OpenOptions::new();
1918 options.write(true).truncate(true);
1919 match std::fs::symlink_metadata(gitfile) {
1920 Ok(metadata) if metadata_is_regular_file(&metadata) => {}
1921 Ok(_) => {
1922 return Err(std::io::Error::new(
1923 std::io::ErrorKind::InvalidData,
1924 "refusing to replace non-file audit worktree .git entry",
1925 ));
1926 }
1927 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1928 options.create_new(true);
1929 }
1930 Err(error) => return Err(error),
1931 }
1932 let mut file = options.open(gitfile)?;
1933 file.write_all(UNREGISTERED_GITDIR_STUB.as_bytes())?;
1934 file.sync_all()
1935}
1936
1937fn trusted_worktree_admin_dir(repo_root: &Path, path: &Path) -> Option<PathBuf> {
1938 let gitfile = path.join(".git");
1939 let metadata = std::fs::symlink_metadata(&gitfile).ok()?;
1940 if !metadata_is_regular_file(&metadata) {
1941 return None;
1942 }
1943 let contents = std::fs::read_to_string(&gitfile).ok()?;
1944 let admin_dir = parse_worktree_gitdir(&contents)?;
1945 if !is_fallow_admin_dir(&admin_dir) {
1946 return None;
1947 }
1948 let common_dir = fallow_engine::changed_files::resolve_git_common_dir(repo_root).ok()?;
1949 let worktrees_dir = dunce::canonicalize(common_dir.join("worktrees")).ok()?;
1950 let admin_parent = dunce::canonicalize(admin_dir.parent()?).ok()?;
1951 if admin_parent != worktrees_dir {
1952 return None;
1953 }
1954 let backlink = std::fs::read_to_string(admin_dir.join("gitdir")).ok()?;
1955 let expected_gitfile = dunce::canonicalize(&gitfile).ok()?;
1956 let actual_gitfile = dunce::canonicalize(Path::new(backlink.trim())).ok()?;
1957 (actual_gitfile == expected_gitfile).then_some(admin_dir)
1958}
1959
1960fn parse_worktree_gitdir(contents: &str) -> Option<PathBuf> {
1962 contents
1963 .lines()
1964 .find_map(|line| line.trim().strip_prefix("gitdir:"))
1965 .map(|rest| PathBuf::from(rest.trim()))
1966}
1967
1968fn is_fallow_admin_dir(admin_dir: &Path) -> bool {
1971 admin_dir
1972 .file_name()
1973 .and_then(|name| name.to_str())
1974 .is_some_and(|name| name.starts_with("fallow-audit-base-"))
1975}
1976
1977pub fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
1978 let mut command = Command::new("git");
1979 command.args(["rev-parse", rev]).current_dir(root);
1980 clear_ambient_git_env(&mut command);
1981 let output = command.output().ok()?;
1982 if !output.status.success() {
1983 return None;
1984 }
1985 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1986}
1987
1988pub fn git_toplevel(root: &Path) -> Option<PathBuf> {
1989 let mut command = Command::new("git");
1990 command
1991 .args(["rev-parse", "--show-toplevel"])
1992 .current_dir(root);
1993 clear_ambient_git_env(&mut command);
1994 let output = command.output().ok()?;
1995 if !output.status.success() {
1996 return None;
1997 }
1998 let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1999 Some(dunce::canonicalize(&path).unwrap_or(path))
2000}
2001
2002fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
2003 let Some(worktrees) = list_audit_worktrees(repo_root) else {
2004 return false;
2005 };
2006 worktrees.iter().any(|worktree| paths_equal(worktree, path))
2007}
2008
2009pub fn paths_equal(left: &Path, right: &Path) -> bool {
2010 if left == right {
2011 return true;
2012 }
2013 match (dunce::canonicalize(left), dunce::canonicalize(right)) {
2014 (Ok(left), Ok(right)) => left == right,
2015 _ => false,
2016 }
2017}
2018
2019pub fn remove_audit_worktree(repo_root: &Path, path: &Path) {
2020 let mut command = Command::new("git");
2021 command
2022 .args([
2023 "worktree",
2024 "remove",
2025 "--force",
2026 path.to_string_lossy().as_ref(),
2027 ])
2028 .current_dir(repo_root);
2029 clear_ambient_git_env(&mut command);
2030 match crate::signal::scoped_child::output(&mut command) {
2031 Ok(output) => {
2032 if !output.status.success() && path.exists() {
2033 let stderr = String::from_utf8_lossy(&output.stderr);
2034 tracing::warn!(
2035 path = %path.display(),
2036 stderr = %stderr.trim(),
2037 "git worktree remove failed; the directory remains and may leak",
2038 );
2039 }
2040 }
2041 Err(err) => {
2042 tracing::warn!(
2043 path = %path.display(),
2044 error = %err,
2045 "git worktree remove subprocess failed to spawn",
2046 );
2047 }
2048 }
2049}
2050
2051pub fn sweep_orphan_audit_worktrees(repo_root: &Path) {
2052 sweep_orphan_audit_worktrees_in(repo_root, &std::env::temp_dir());
2053}
2054
2055pub fn sweep_orphan_audit_worktrees_in(repo_root: &Path, temp_root: &Path) {
2061 if deregister_legacy_orphan_worktrees(repo_root) {
2066 let mut command = Command::new("git");
2067 command
2068 .args(["worktree", "prune", "--expire=now"])
2069 .current_dir(repo_root);
2070 clear_ambient_git_env(&mut command);
2071 let _ = command.output();
2072 }
2073
2074 for path in scan_non_reusable_orphan_paths(temp_root) {
2078 let _ = std::fs::remove_dir_all(&path);
2079 }
2080}
2081
2082fn deregister_legacy_orphan_worktrees(repo_root: &Path) -> bool {
2085 let Some(worktrees) = list_audit_worktrees(repo_root) else {
2086 return false;
2087 };
2088 let mut removed_any = false;
2089 for path in worktrees {
2090 if !is_fallow_audit_worktree_path(&path)
2091 || is_reusable_audit_worktree_path(&path)
2092 || audit_worktree_process_is_alive(&path)
2093 {
2094 continue;
2095 }
2096 remove_audit_worktree(repo_root, &path);
2097 let _ = std::fs::remove_dir_all(&path);
2098 removed_any = true;
2099 }
2100 removed_any
2101}
2102
2103fn scan_non_reusable_orphan_paths(temp: &Path) -> Vec<PathBuf> {
2106 let Ok(entries) = std::fs::read_dir(temp) else {
2107 return Vec::new();
2108 };
2109 let mut paths = Vec::new();
2110 for entry in entries.flatten() {
2111 let name = entry.file_name();
2112 let Some(name) = name.to_str() else {
2113 continue;
2114 };
2115 let Some(pid) = audit_worktree_pid(name) else {
2116 continue;
2117 };
2118 if process_is_alive(pid) || !entry.path().is_dir() {
2119 continue;
2120 }
2121 paths.push(temp.join(name));
2122 }
2123 paths
2124}
2125
2126pub fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
2127 let mut command = Command::new("git");
2128 command
2129 .args(["worktree", "list", "--porcelain"])
2130 .current_dir(repo_root);
2131 clear_ambient_git_env(&mut command);
2132 let output = command.output().ok()?;
2133 if !output.status.success() {
2134 return None;
2135 }
2136 Some(parse_worktree_list(&String::from_utf8_lossy(
2137 &output.stdout,
2138 )))
2139}
2140
2141pub fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
2142 output
2143 .lines()
2144 .filter_map(|line| line.strip_prefix("worktree "))
2145 .map(PathBuf::from)
2146 .filter(|path| is_fallow_audit_worktree_path(path))
2147 .collect()
2148}
2149
2150pub fn is_fallow_audit_worktree_path(path: &Path) -> bool {
2151 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
2152 return false;
2153 };
2154 name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
2155}
2156
2157pub fn is_reusable_audit_worktree_path(path: &Path) -> bool {
2158 path.file_name()
2159 .and_then(|name| name.to_str())
2160 .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
2161}
2162
2163fn path_is_inside_temp_dir(path: &Path) -> bool {
2164 let temp = std::env::temp_dir();
2165 let simple_path = dunce::simplified(path);
2166 let simple_temp = dunce::simplified(&temp);
2167 if simple_path.starts_with(simple_temp) {
2168 return true;
2169 }
2170 let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
2171 return false;
2172 };
2173 let simple_canonical_temp = dunce::simplified(&canonical_temp);
2174 simple_path.starts_with(simple_canonical_temp)
2175 || std::fs::canonicalize(path).is_ok_and(|canonical_path| {
2176 dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
2177 })
2178}
2179
2180fn audit_worktree_process_is_alive(path: &Path) -> bool {
2181 let Some(pid) = path
2182 .file_name()
2183 .and_then(|name| name.to_str())
2184 .and_then(audit_worktree_pid)
2185 else {
2186 return false;
2187 };
2188 process_is_alive(pid)
2189}
2190
2191pub fn audit_worktree_pid(name: &str) -> Option<u32> {
2192 name.strip_prefix("fallow-audit-base-")?
2193 .split('-')
2194 .next()?
2195 .parse()
2196 .ok()
2197}
2198
2199#[cfg(unix)]
2200pub fn process_is_alive(pid: u32) -> bool {
2201 Command::new("kill")
2202 .args(["-0", &pid.to_string()])
2203 .output()
2204 .is_ok_and(|output| output.status.success())
2205}
2206
2207#[cfg(windows)]
2208pub fn process_is_alive(pid: u32) -> bool {
2209 windows_process::is_alive(pid)
2210}
2211
2212#[cfg(not(any(unix, windows)))]
2213pub fn process_is_alive(_pid: u32) -> bool {
2214 true
2215}
2216
2217#[cfg(windows)]
2218#[allow(
2219 unsafe_code,
2220 reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
2221)]
2222mod windows_process {
2223 use windows_sys::Win32::Foundation::{
2224 CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
2225 WAIT_OBJECT_0,
2226 };
2227 use windows_sys::Win32::System::Threading::{
2228 OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
2229 };
2230
2231 struct ProcessHandle(HANDLE);
2235
2236 impl Drop for ProcessHandle {
2237 fn drop(&mut self) {
2238 unsafe {
2242 CloseHandle(self.0);
2243 }
2244 }
2245 }
2246
2247 pub fn is_alive(pid: u32) -> bool {
2255 let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
2259 if raw.is_null() {
2260 let err = unsafe { GetLastError() };
2263 #[expect(
2264 clippy::match_same_arms,
2265 reason = "named arm documents the cross-session case"
2266 )]
2267 return match err {
2268 ERROR_INVALID_PARAMETER => false,
2269 ERROR_ACCESS_DENIED => true,
2270 _ => true,
2271 };
2272 }
2273 let handle = ProcessHandle(raw);
2274 let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
2277 wait_result != WAIT_OBJECT_0
2278 }
2279}
2280
2281impl Drop for BaseWorktree {
2282 fn drop(&mut self) {
2283 if self.persistent {
2284 return;
2285 }
2286 let _ = std::fs::remove_dir_all(&self.path);
2290 }
2291}
2292
2293#[cfg(test)]
2294mod tests {
2295 use super::*;
2296
2297 #[test]
2303 fn non_reusable_worktree_paths_are_unique_under_concurrency() {
2304 const N: usize = 64;
2305 let barrier = std::sync::Barrier::new(N);
2306 let paths = std::sync::Mutex::new(Vec::with_capacity(N));
2307 std::thread::scope(|s| {
2308 for _ in 0..N {
2309 let barrier = &barrier;
2310 let paths = &paths;
2311 s.spawn(move || {
2312 barrier.wait();
2313 let path = non_reusable_worktree_path().expect("path should build");
2314 paths.lock().unwrap().push(path);
2315 });
2316 }
2317 });
2318 let mut paths = paths.into_inner().unwrap();
2319 assert_eq!(paths.len(), N);
2320 paths.sort();
2321 paths.dedup();
2322 assert_eq!(paths.len(), N, "non-reusable worktree paths collided");
2323 }
2324
2325 #[test]
2327 fn non_reusable_worktree_path_pid_is_parseable() {
2328 let path = non_reusable_worktree_path().expect("path should build");
2329 let name = path.file_name().unwrap().to_str().unwrap();
2330 assert!(is_fallow_audit_worktree_path(&path));
2331 assert!(!is_reusable_audit_worktree_path(&path));
2332 assert_eq!(audit_worktree_pid(name), Some(std::process::id()));
2333 }
2334
2335 #[test]
2336 fn directory_size_bytes_sums_regular_files_recursively() {
2337 let temp = tempfile::TempDir::new().expect("temp dir should be created");
2338 let root = temp.path().join("cache");
2339 std::fs::create_dir_all(root.join("node_modules/dep")).expect("tree should be created");
2340 std::fs::write(root.join("a.txt"), vec![0u8; 10]).expect("file should be written");
2341 std::fs::write(root.join("node_modules/dep/b.js"), vec![0u8; 32])
2342 .expect("nested file should be written");
2343 std::fs::write(root.join(".gitignore"), "node_modules\n")
2346 .expect("gitignore should be written");
2347
2348 let size = directory_size_bytes(&root).expect("size walk should succeed");
2349 assert_eq!(size, 10 + 32 + "node_modules\n".len() as u64);
2350 assert_eq!(
2351 directory_size_bytes(&temp.path().join("missing")),
2352 None,
2353 "an absent directory reports no size",
2354 );
2355 }
2356
2357 #[cfg(unix)]
2358 #[test]
2359 fn directory_size_bytes_never_follows_symlinks() {
2360 let temp = tempfile::TempDir::new().expect("temp dir should be created");
2361 let root = temp.path().join("cache");
2362 let outside = temp.path().join("outside");
2363 std::fs::create_dir_all(&root).expect("root should be created");
2364 std::fs::create_dir_all(&outside).expect("outside dir should be created");
2365 std::fs::write(outside.join("big.bin"), vec![0u8; 4096])
2366 .expect("outside file should be written");
2367 std::os::unix::fs::symlink(&outside, root.join("link-dir"))
2368 .expect("dir symlink should be created");
2369 std::os::unix::fs::symlink(outside.join("big.bin"), root.join("link-file"))
2370 .expect("file symlink should be created");
2371
2372 assert_eq!(
2373 directory_size_bytes(&root),
2374 Some(0),
2375 "symlinked directories and files must not be traversed or counted",
2376 );
2377 }
2378
2379 #[cfg(unix)]
2380 #[test]
2381 fn cache_sidecar_open_does_not_follow_symlinks() {
2382 let temp = tempfile::TempDir::new().expect("temp dir should be created");
2383 let victim = temp.path().join("victim");
2384 let sidecar = temp.path().join("cache.lock");
2385 std::fs::write(&victim, "unchanged\n").expect("victim should be written");
2386 std::os::unix::fs::symlink(&victim, &sidecar).expect("sidecar symlink should be created");
2387
2388 assert!(open_or_create_owned_sidecar(&sidecar).is_err());
2389 assert_eq!(
2390 std::fs::read_to_string(victim).expect("victim should remain readable"),
2391 "unchanged\n",
2392 );
2393 }
2394}