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 ReclaimedLegacyRegistered,
742 KeptLegacyDeregistered,
747 KeptFresh,
749 KeptOwnerLive,
752 KeptOwnerUnverifiable(std::io::ErrorKind),
755 KeptAgeGcDisabled,
758 KeptGraceSeeded,
761 KeptLockOnly,
764 KeptRecreated,
767 KeptNotOwned,
770 SkippedLocked,
772 RemoveFailed,
774}
775
776impl SweepDisposition {
777 #[must_use]
778 pub const fn decision(self) -> SweepDecision {
779 match self {
780 Self::ReclaimedOrphan | Self::ReclaimedAged | Self::ReclaimedLegacyRegistered => {
781 SweepDecision::Removed
782 }
783 Self::KeptLegacyDeregistered
784 | Self::KeptFresh
785 | Self::KeptOwnerLive
786 | Self::KeptOwnerUnverifiable(_)
787 | Self::KeptAgeGcDisabled
788 | Self::KeptGraceSeeded
789 | Self::KeptLockOnly
790 | Self::KeptRecreated
791 | Self::KeptNotOwned => SweepDecision::Kept,
792 Self::SkippedLocked => SweepDecision::Skipped,
793 Self::RemoveFailed => SweepDecision::Failed,
794 }
795 }
796
797 #[must_use]
800 pub const fn reason(self) -> &'static str {
801 match self {
802 Self::ReclaimedOrphan => "orphaned-sidecars",
803 Self::ReclaimedAged => "aged-out",
804 Self::ReclaimedLegacyRegistered => "legacy-registered",
805 Self::KeptLegacyDeregistered => "legacy-deregistered",
806 Self::KeptFresh => "fresh",
807 Self::KeptOwnerLive => "owner-live",
808 Self::KeptOwnerUnverifiable(_) => "owner-unverifiable",
809 Self::KeptAgeGcDisabled => "age-gc-disabled",
810 Self::KeptGraceSeeded => "grace-seeded",
811 Self::KeptLockOnly => "lock-only",
812 Self::KeptRecreated => "recreated",
813 Self::KeptNotOwned => "not-owned",
814 Self::SkippedLocked => "lock-contention",
815 Self::RemoveFailed => "remove-failed",
816 }
817 }
818
819 const fn counts_toward_summary(self) -> bool {
822 matches!(self, Self::ReclaimedOrphan | Self::ReclaimedAged)
823 }
824}
825
826#[derive(Debug, Clone)]
828pub struct SweepEntry {
829 pub path: PathBuf,
830 pub pass: SweepPass,
831 pub disposition: SweepDisposition,
832 pub age_days: Option<u64>,
834 pub owner_root: Option<PathBuf>,
836 pub size_bytes: Option<u64>,
839}
840
841#[derive(Debug, Default)]
845pub struct SweepReport {
846 pub entries: Vec<SweepEntry>,
847 pub removed: u32,
848}
849
850impl SweepReport {
851 fn push(&mut self, entry: SweepEntry) {
852 if entry.disposition.counts_toward_summary() {
853 self.removed += 1;
854 }
855 self.entries.push(entry);
856 }
857}
858
859const GC_LOCK_CONTEXT: &str = "gc sweep skips the entry";
860
861pub fn log_sweep_entries(report: &SweepReport, mode: SweepMode, max_age: Option<Duration>) {
865 let threshold_days = max_age.map(|age| age.as_secs() / SECONDS_PER_DAY);
866 for entry in &report.entries {
867 let owner_probe_error = match entry.disposition {
868 SweepDisposition::KeptOwnerUnverifiable(kind) => Some(kind),
869 _ => None,
870 };
871 tracing::debug!(
872 path = %entry.path.display(),
873 pass = entry.pass.as_str(),
874 mode = mode.as_str(),
875 decision = entry.disposition.decision().as_str(),
876 reason = entry.disposition.reason(),
877 age_days = entry.age_days,
878 threshold_days,
879 owner_root = entry
880 .owner_root
881 .as_ref()
882 .map(|owner| tracing::field::display(owner.display())),
883 owner_probe_error = owner_probe_error.map(tracing::field::debug),
884 "audit cache sweep considered entry",
885 );
886 }
887}
888
889pub fn sweep_reusable_caches_with_report(
895 repo_root: &Path,
896 max_age: Option<Duration>,
897 scan_root: &Path,
898 mode: SweepMode,
899 sizes: SweepSizes,
900) -> SweepReport {
901 let now = SystemTime::now();
902 let mut report = SweepReport::default();
903
904 legacy_registered_pass(repo_root, mode, sizes, now, &mut report);
910
911 let owned_path = reusable_audit_worktree_path(repo_root);
915 let mut paths = vec![owned_path.clone()];
916 paths.extend(scan_legacy_reusable_cache_paths(repo_root, scan_root));
917 paths.sort();
918 paths.dedup();
919
920 let scoped: FxHashSet<&PathBuf> = paths.iter().collect();
927 let foreign: Vec<PathBuf> = scan_all_reusable_cache_paths(scan_root)
928 .into_iter()
929 .filter(|path| !scoped.contains(path))
930 .collect();
931 drop(scoped);
932
933 let mut size_map = measure_entry_sizes(sizes, paths.iter().chain(foreign.iter()));
936
937 for path in &paths {
938 if !cache_entry_has_presence(path) {
944 continue;
945 }
946 let pass = if *path == owned_path {
947 SweepPass::Owned
948 } else {
949 SweepPass::Legacy
950 };
951 let (age_days, owner_root) = entry_probe_metadata(path, now);
952 let disposition = match mode {
953 SweepMode::Apply => reclaim_reusable_cache_entry(repo_root, path, max_age, now),
954 SweepMode::DryRun => classify_entry_dry_run(path, max_age, now, OwnerGate::Off),
955 };
956 report.push(SweepEntry {
957 path: path.clone(),
958 pass,
959 disposition,
960 age_days,
961 owner_root,
962 size_bytes: size_map.remove(path).flatten(),
963 });
964 }
965 for path in &foreign {
966 let (age_days, owner_root) = entry_probe_metadata(path, now);
967 let disposition = match mode {
968 SweepMode::Apply => reclaim_foreign_cache_entry(repo_root, path, max_age, now),
969 SweepMode::DryRun => classify_entry_dry_run(path, max_age, now, OwnerGate::On),
970 };
971 report.push(SweepEntry {
972 path: path.clone(),
973 pass: SweepPass::Foreign,
974 disposition,
975 age_days,
976 owner_root,
977 size_bytes: size_map.remove(path).flatten(),
978 });
979 }
980 report
981}
982
983fn entry_probe_metadata(path: &Path, now: SystemTime) -> (Option<u64>, Option<PathBuf>) {
986 let age_days = last_used_mtime(path)
987 .and_then(|mtime| now.duration_since(mtime).ok())
988 .map(|age| age.as_secs() / SECONDS_PER_DAY);
989 (age_days, read_last_used_owner(path))
990}
991
992fn legacy_registered_pass(
1002 repo_root: &Path,
1003 mode: SweepMode,
1004 sizes: SweepSizes,
1005 now: SystemTime,
1006 report: &mut SweepReport,
1007) {
1008 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1009 return;
1010 };
1011 let candidates: Vec<PathBuf> = worktrees
1012 .into_iter()
1013 .filter(|path| is_reusable_audit_worktree_path(path))
1014 .collect();
1015 let mut size_map = measure_entry_sizes(sizes, candidates.iter());
1016 let owned_path = reusable_audit_worktree_path(repo_root);
1017 let mut deregistered = false;
1018 for path in candidates {
1019 let pass = if paths_equal(&path, &owned_path) {
1020 SweepPass::Owned
1021 } else {
1022 SweepPass::Legacy
1023 };
1024 let (age_days, owner_root) = entry_probe_metadata(&path, now);
1025 let size_bytes = size_map.remove(&path).flatten();
1026 let is_current_path = pass == SweepPass::Owned;
1027 let disposition = match mode {
1028 SweepMode::DryRun => {
1029 if !audit_worktree_is_registered(repo_root, &path) {
1030 continue;
1031 }
1032 if is_current_path {
1033 SweepDisposition::KeptLegacyDeregistered
1034 } else {
1035 SweepDisposition::ReclaimedLegacyRegistered
1036 }
1037 }
1038 SweepMode::Apply => {
1039 let Some(_lock) = ReusableWorktreeLock::try_acquire(
1040 &path,
1041 "legacy deregistration skips the entry",
1042 ) else {
1043 report.push(SweepEntry {
1044 path,
1045 pass,
1046 disposition: SweepDisposition::SkippedLocked,
1047 age_days,
1048 owner_root,
1049 size_bytes,
1050 });
1051 continue;
1052 };
1053 if !audit_worktree_is_registered(repo_root, &path) {
1054 continue;
1055 }
1056 let head = is_current_path
1057 .then(|| legacy_reusable_sha(&path))
1058 .flatten();
1059 if unregister_worktree_checked(repo_root, &path).is_err() {
1060 report.push(SweepEntry {
1061 path,
1062 pass,
1063 disposition: SweepDisposition::RemoveFailed,
1064 age_days,
1065 owner_root,
1066 size_bytes,
1067 });
1068 continue;
1069 }
1070 let disposition = if is_current_path {
1071 if let Some(head) = head {
1072 let _ = write_reusable_sha(&path, &head);
1073 }
1074 SweepDisposition::KeptLegacyDeregistered
1075 } else if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
1076 tracing::warn!(
1077 path = %path.display(),
1078 error = %error,
1079 "failed to remove released SHA-keyed audit cache",
1080 );
1081 SweepDisposition::RemoveFailed
1082 } else {
1083 SweepDisposition::ReclaimedLegacyRegistered
1084 };
1085 deregistered = true;
1086 disposition
1087 }
1088 };
1089 report.push(SweepEntry {
1090 path,
1091 pass,
1092 disposition,
1093 age_days,
1094 owner_root,
1095 size_bytes,
1096 });
1097 }
1098 if deregistered {
1099 let mut command = Command::new("git");
1100 command
1101 .args(["worktree", "prune", "--expire=now"])
1102 .current_dir(repo_root);
1103 clear_ambient_git_env(&mut command);
1104 let _ = command.output();
1105 }
1106}
1107
1108fn legacy_reusable_sha(path: &Path) -> Option<String> {
1112 if reusable_worktree_sha_path(path).exists()
1113 || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
1114 {
1115 return None;
1116 }
1117 git_rev_parse(path, "HEAD")
1118}
1119
1120fn scan_legacy_reusable_cache_paths(repo_root: &Path, scan_root: &Path) -> Vec<PathBuf> {
1125 let Some(prefix) = legacy_reusable_cache_repo_prefix(repo_root) else {
1126 return Vec::new();
1127 };
1128 scan_cache_paths_with_hex_suffix(&prefix, scan_root)
1129}
1130
1131fn scan_root_owned_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
1132 let Some(prefix) = root_owned_cache_repo_prefix(repo_root) else {
1133 return Vec::new();
1134 };
1135 scan_cache_paths_with_hex_suffix(&prefix, &std::env::temp_dir())
1136}
1137
1138fn scan_all_reusable_cache_paths(scan_root: &Path) -> Vec<PathBuf> {
1144 const GLOBAL_CACHE_PREFIX: &str = "fallow-audit-base-cache-";
1145
1146 let Ok(entries) = std::fs::read_dir(scan_root) else {
1147 return Vec::new();
1148 };
1149 let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
1150 let mut paths = Vec::new();
1151 for entry in entries.flatten() {
1152 let name = entry.file_name();
1153 let Some(name) = name.to_str() else {
1154 continue;
1155 };
1156 let cache_name = strip_cache_sidecar_suffix(name);
1157 let Some(hash_suffix) = cache_name.strip_prefix(GLOBAL_CACHE_PREFIX) else {
1158 continue;
1159 };
1160 if !cache_hash_suffix_is_valid(hash_suffix) {
1161 continue;
1162 }
1163 let path = scan_root.join(cache_name);
1164 if seen.insert(path.clone()) {
1165 paths.push(path);
1166 }
1167 }
1168 paths
1169}
1170
1171fn cache_hash_suffix_is_valid(suffix: &str) -> bool {
1172 fn is_hex16(part: &str) -> bool {
1173 part.len() == 16 && part.bytes().all(|byte| byte.is_ascii_hexdigit())
1174 }
1175 if let Some((repo, root)) = suffix.split_once("-root-") {
1176 return is_hex16(repo) && is_hex16(root);
1177 }
1178 suffix
1179 .split_once('-')
1180 .is_some_and(|(repo, sha)| is_hex16(repo) && is_hex16(sha))
1181}
1182
1183fn scan_cache_paths_with_hex_suffix(prefix: &str, scan_root: &Path) -> Vec<PathBuf> {
1184 let Ok(entries) = std::fs::read_dir(scan_root) else {
1185 return Vec::new();
1186 };
1187 let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
1188 let mut paths = Vec::new();
1189 for entry in entries.flatten() {
1190 let name = entry.file_name();
1191 let Some(name) = name.to_str() else {
1192 continue;
1193 };
1194 let cache_name = strip_cache_sidecar_suffix(name);
1195 let Some(hash_suffix) = cache_name.strip_prefix(prefix) else {
1196 continue;
1197 };
1198 if hash_suffix.len() != 16 || !hash_suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1199 continue;
1200 }
1201 let path = scan_root.join(cache_name);
1202 if seen.insert(path.clone()) {
1203 paths.push(path);
1204 }
1205 }
1206 paths
1207}
1208
1209fn strip_cache_sidecar_suffix(name: &str) -> &str {
1213 for suffix in [
1214 REUSABLE_LAST_USED_SUFFIX,
1215 REUSABLE_SHA_SUFFIX,
1216 REUSABLE_LOCK_SUFFIX,
1217 ] {
1218 if let Some(stripped) = name.strip_suffix(suffix) {
1219 return stripped;
1220 }
1221 }
1222 name
1223}
1224
1225fn reclaim_reusable_cache_entry(
1227 repo_root: &Path,
1228 path: &Path,
1229 max_age: Option<Duration>,
1230 now: SystemTime,
1231) -> SweepDisposition {
1232 if !path.exists() {
1244 return reclaim_orphan_cache_entry(repo_root, path);
1245 }
1246 let Some(max_age) = max_age else {
1247 return SweepDisposition::KeptAgeGcDisabled;
1248 };
1249 reclaim_aged_cache_entry(repo_root, path, max_age, now)
1250}
1251
1252pub fn reclaim_orphan_cache_entry(repo_root: &Path, path: &Path) -> SweepDisposition {
1258 let Some(_lock) = ReusableWorktreeLock::try_acquire(path, GC_LOCK_CONTEXT) else {
1259 return SweepDisposition::SkippedLocked;
1260 };
1261 if path.exists() {
1264 return SweepDisposition::KeptRecreated;
1265 }
1266 match remove_cache_entry_for_sweep(repo_root, path) {
1267 Ok(CacheRemovalOutcome::Removed) => SweepDisposition::ReclaimedOrphan,
1268 Ok(CacheRemovalOutcome::NothingLeft) => SweepDisposition::KeptLockOnly,
1269 Ok(CacheRemovalOutcome::NotOwned) => SweepDisposition::KeptNotOwned,
1270 Err(_) => SweepDisposition::RemoveFailed,
1271 }
1272}
1273
1274fn reclaim_aged_cache_entry(
1280 repo_root: &Path,
1281 path: &Path,
1282 max_age: Duration,
1283 now: SystemTime,
1284) -> SweepDisposition {
1285 let Some(mtime) = last_used_mtime(path) else {
1286 record_last_used(path, repo_root);
1287 return SweepDisposition::KeptGraceSeeded;
1288 };
1289 remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
1290}
1291
1292fn reclaim_foreign_cache_entry(
1304 repo_root: &Path,
1305 path: &Path,
1306 max_age: Option<Duration>,
1307 now: SystemTime,
1308) -> SweepDisposition {
1309 if !path.exists() {
1312 return reclaim_orphan_cache_entry(repo_root, path);
1313 }
1314 if let Some(owner) = read_last_used_owner(path) {
1315 match probe_owner_liveness(&owner) {
1316 OwnerLiveness::Live => return SweepDisposition::KeptOwnerLive,
1317 OwnerLiveness::Unverifiable(kind) => {
1318 return SweepDisposition::KeptOwnerUnverifiable(kind);
1319 }
1320 OwnerLiveness::Dead => {}
1321 }
1322 }
1323 let Some(max_age) = max_age else {
1324 return SweepDisposition::KeptAgeGcDisabled;
1325 };
1326 let Some(mtime) = last_used_mtime(path) else {
1327 touch_last_used(path);
1328 return SweepDisposition::KeptGraceSeeded;
1329 };
1330 remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
1331}
1332
1333enum OwnerLiveness {
1334 Live,
1335 Dead,
1336 Unverifiable(std::io::ErrorKind),
1337}
1338
1339fn probe_owner_liveness(owner: &Path) -> OwnerLiveness {
1349 match std::fs::metadata(owner) {
1350 Ok(_) => OwnerLiveness::Live,
1351 Err(error) if error.kind() == std::io::ErrorKind::NotFound => OwnerLiveness::Dead,
1352 Err(error) => OwnerLiveness::Unverifiable(error.kind()),
1353 }
1354}
1355
1356#[derive(Clone, Copy, PartialEq, Eq)]
1359enum OwnerGate {
1360 On,
1361 Off,
1362}
1363
1364fn classify_entry_dry_run(
1370 path: &Path,
1371 max_age: Option<Duration>,
1372 now: SystemTime,
1373 owner_gate: OwnerGate,
1374) -> SweepDisposition {
1375 if !path.exists() {
1376 if !reusable_cache_entry_exists(path) {
1377 return SweepDisposition::KeptLockOnly;
1378 }
1379 return classify_would_remove(path, SweepDisposition::ReclaimedOrphan);
1380 }
1381 if owner_gate == OwnerGate::On
1382 && let Some(owner) = read_last_used_owner(path)
1383 {
1384 match probe_owner_liveness(&owner) {
1385 OwnerLiveness::Live => return SweepDisposition::KeptOwnerLive,
1386 OwnerLiveness::Unverifiable(kind) => {
1387 return SweepDisposition::KeptOwnerUnverifiable(kind);
1388 }
1389 OwnerLiveness::Dead => {}
1390 }
1391 }
1392 let Some(max_age) = max_age else {
1393 return SweepDisposition::KeptAgeGcDisabled;
1394 };
1395 let Some(mtime) = last_used_mtime(path) else {
1396 return SweepDisposition::KeptGraceSeeded;
1397 };
1398 let Ok(age) = now.duration_since(mtime) else {
1399 return SweepDisposition::KeptFresh;
1400 };
1401 if age < max_age {
1402 return SweepDisposition::KeptFresh;
1403 }
1404 classify_would_remove(path, SweepDisposition::ReclaimedAged)
1405}
1406
1407fn classify_would_remove(path: &Path, removed: SweepDisposition) -> SweepDisposition {
1411 match cache_entry_ownership(path) {
1412 Ok(CacheEntryOwnership::Owned) => removed,
1413 Ok(CacheEntryOwnership::Unowned(_)) => SweepDisposition::KeptNotOwned,
1414 Err(_) => SweepDisposition::RemoveFailed,
1415 }
1416}
1417
1418fn last_used_mtime(path: &Path) -> Option<SystemTime> {
1419 std::fs::metadata(reusable_worktree_last_used_path(path))
1420 .ok()
1421 .and_then(|metadata| metadata.modified().ok())
1422}
1423
1424fn remove_entry_past_max_age(
1425 repo_root: &Path,
1426 path: &Path,
1427 max_age: Duration,
1428 now: SystemTime,
1429 mtime: SystemTime,
1430) -> SweepDisposition {
1431 let Ok(age) = now.duration_since(mtime) else {
1432 return SweepDisposition::KeptFresh;
1433 };
1434 if age < max_age {
1435 return SweepDisposition::KeptFresh;
1436 }
1437 let Some(_lock) = ReusableWorktreeLock::try_acquire(path, GC_LOCK_CONTEXT) else {
1438 return SweepDisposition::SkippedLocked;
1439 };
1440 match remove_cache_entry_for_sweep(repo_root, path) {
1441 Ok(CacheRemovalOutcome::Removed) => SweepDisposition::ReclaimedAged,
1442 Ok(CacheRemovalOutcome::NothingLeft) => SweepDisposition::KeptLockOnly,
1443 Ok(CacheRemovalOutcome::NotOwned) => SweepDisposition::KeptNotOwned,
1444 Err(err) => {
1445 tracing::warn!(
1446 path = %path.display(),
1447 error = %err,
1448 "failed to remove stale reusable audit worktree entry; entry may leak",
1449 );
1450 SweepDisposition::RemoveFailed
1451 }
1452 }
1453}
1454
1455enum CacheRemovalOutcome {
1456 Removed,
1457 NothingLeft,
1459 NotOwned,
1461}
1462
1463fn remove_cache_entry_for_sweep(
1467 repo_root: &Path,
1468 path: &Path,
1469) -> std::io::Result<CacheRemovalOutcome> {
1470 if matches!(
1471 cache_entry_ownership(path)?,
1472 CacheEntryOwnership::Unowned(_)
1473 ) {
1474 return Ok(CacheRemovalOutcome::NotOwned);
1475 }
1476 remove_reusable_cache_entry_locked(repo_root, path).map(|removed| {
1477 if removed {
1478 CacheRemovalOutcome::Removed
1479 } else {
1480 CacheRemovalOutcome::NothingLeft
1481 }
1482 })
1483}
1484
1485fn directory_size_bytes(root: &Path) -> Option<u64> {
1494 let root_entries = std::fs::read_dir(root).ok()?;
1495 let mut total: u64 = 0;
1496 let mut stack = vec![root_entries];
1497 while let Some(entries) = stack.pop() {
1498 for entry in entries.flatten() {
1499 let Ok(metadata) = entry.metadata() else {
1500 continue;
1501 };
1502 if metadata.is_dir() {
1503 if let Ok(child) = std::fs::read_dir(entry.path()) {
1504 stack.push(child);
1505 }
1506 } else if metadata_is_regular_file(&metadata) {
1507 total = total.saturating_add(metadata.len());
1508 }
1509 }
1510 }
1511 Some(total)
1512}
1513
1514fn measure_entry_sizes<'a>(
1517 sizes: SweepSizes,
1518 paths: impl Iterator<Item = &'a PathBuf>,
1519) -> FxHashMap<PathBuf, Option<u64>> {
1520 use rayon::iter::{IntoParallelRefIterator as _, ParallelIterator as _};
1521
1522 if sizes == SweepSizes::Skip {
1523 return FxHashMap::default();
1524 }
1525 let paths: Vec<&PathBuf> = paths.collect();
1526 paths
1527 .par_iter()
1528 .map(|path| ((*path).clone(), directory_size_bytes(path)))
1529 .collect()
1530}
1531
1532pub fn canonical_root_hash(root: &Path) -> u64 {
1533 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1534 xxh3_64(&path_identity_bytes(&canonical_root))
1535}
1536
1537#[cfg(unix)]
1538fn path_identity_bytes(path: &Path) -> Vec<u8> {
1539 use std::os::unix::ffi::OsStrExt as _;
1540
1541 path.as_os_str().as_bytes().to_vec()
1542}
1543
1544#[cfg(windows)]
1545fn path_identity_bytes(path: &Path) -> Vec<u8> {
1546 use std::os::windows::ffi::OsStrExt as _;
1547
1548 path.as_os_str()
1549 .encode_wide()
1550 .flat_map(u16::to_le_bytes)
1551 .collect()
1552}
1553
1554#[cfg(not(any(unix, windows)))]
1555fn path_identity_bytes(path: &Path) -> Vec<u8> {
1556 path.to_string_lossy().as_bytes().to_vec()
1557}
1558
1559pub fn reusable_audit_worktree_path(requested_root: &Path) -> PathBuf {
1560 let root_hash = canonical_root_hash(requested_root);
1561 let repo_hash = git_toplevel(requested_root)
1562 .as_deref()
1563 .map_or(root_hash, canonical_root_hash);
1564 std::env::temp_dir().join(format!(
1565 "fallow-audit-base-cache-{repo_hash:016x}-root-{root_hash:016x}"
1566 ))
1567}
1568
1569fn root_owned_cache_repo_prefix(requested_root: &Path) -> Option<String> {
1570 let git_root = git_toplevel(requested_root)?;
1571 let repo_hash = canonical_root_hash(&git_root);
1572 Some(format!("fallow-audit-base-cache-{repo_hash:016x}-root-"))
1573}
1574
1575fn legacy_reusable_cache_repo_prefix(requested_root: &Path) -> Option<String> {
1576 let git_root = git_toplevel(requested_root)?;
1577 let repo_hash = canonical_root_hash(&git_root);
1578 Some(format!("fallow-audit-base-cache-{repo_hash:016x}-"))
1579}
1580
1581#[cfg(test)]
1582pub fn legacy_reusable_audit_worktree_path(
1583 requested_root: &Path,
1584 base_sha: &str,
1585) -> Option<PathBuf> {
1586 let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
1587 Some(std::env::temp_dir().join(format!(
1588 "{}{sha_prefix}",
1589 legacy_reusable_cache_repo_prefix(requested_root)?
1590 )))
1591}
1592
1593fn reusable_audit_worktree_is_ready(path: &Path, base_sha: &str) -> bool {
1604 if !reusable_cache_directory_is_trusted(path) {
1605 return false;
1606 }
1607 let recorded = read_reusable_sha(path);
1608 if recorded.as_deref() != Some(base_sha) {
1609 return false;
1610 }
1611 repair_unregistered_git_stub(path)
1612}
1613
1614fn read_reusable_sha(path: &Path) -> Option<String> {
1615 const MAX_SHA_SIDECAR_BYTES: u64 = 129;
1616
1617 let sidecar = reusable_worktree_sha_path(path);
1618 let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
1619 if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_SHA_SIDECAR_BYTES {
1620 return None;
1621 }
1622 let mut contents = String::new();
1623 std::fs::File::open(sidecar)
1624 .ok()?
1625 .take(MAX_SHA_SIDECAR_BYTES)
1626 .read_to_string(&mut contents)
1627 .ok()?;
1628 Some(contents.trim().to_owned())
1629}
1630
1631#[cfg(unix)]
1632fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
1633 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1634
1635 let Ok(metadata) = std::fs::symlink_metadata(path) else {
1636 return false;
1637 };
1638 metadata.file_type().is_dir()
1639 && metadata.uid() == rustix::process::geteuid().as_raw()
1640 && metadata.permissions().mode().trailing_zeros() >= 6
1641}
1642
1643#[cfg(not(unix))]
1644fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
1645 std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
1646}
1647
1648fn try_migrate_registered_current_cache(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
1654 if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
1655 return false;
1656 }
1657 let head_matches = git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha);
1658 if !head_matches || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
1659 {
1660 return false;
1661 }
1662 if unregister_worktree_checked(repo_root, path).is_err() {
1663 return false;
1664 }
1665 write_reusable_sha(path, base_sha).is_ok()
1666}
1667
1668#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1675pub struct AuditCacheRemovalReport {
1676 pub found: usize,
1677 pub removed: usize,
1678 pub skipped: usize,
1679 pub dry_run: bool,
1680}
1681
1682pub fn remove_reusable_audit_caches(
1683 requested_root: &Path,
1684 dry_run: bool,
1685) -> std::io::Result<AuditCacheRemovalReport> {
1686 let mut paths = vec![reusable_audit_worktree_path(requested_root)];
1687 paths.extend(scan_legacy_reusable_cache_paths(
1688 requested_root,
1689 &std::env::temp_dir(),
1690 ));
1691 if git_toplevel(requested_root).is_some_and(|root| paths_equal(&root, requested_root)) {
1692 paths.extend(scan_root_owned_cache_paths(requested_root));
1693 }
1694 paths.sort();
1695 paths.dedup();
1696
1697 let mut report = AuditCacheRemovalReport {
1698 found: 0,
1699 removed: 0,
1700 skipped: 0,
1701 dry_run,
1702 };
1703 for path in paths {
1704 if !reusable_cache_entry_exists(&path) {
1705 continue;
1706 }
1707 report.found += 1;
1708 if dry_run {
1709 continue;
1714 }
1715 let Some(_lock) =
1716 ReusableWorktreeLock::try_acquire(&path, "cache removal reports the entry as skipped")
1717 else {
1718 report.skipped += 1;
1719 continue;
1720 };
1721 if remove_reusable_cache_entry_locked(requested_root, &path)? {
1722 report.removed += 1;
1723 }
1724 }
1725 Ok(report)
1726}
1727
1728fn reusable_cache_entry_exists(path: &Path) -> bool {
1729 path_entry_exists(path)
1730 || path_entry_exists(&reusable_worktree_sha_path(path))
1731 || path_entry_exists(&reusable_worktree_last_used_path(path))
1732}
1733
1734fn cache_entry_has_presence(path: &Path) -> bool {
1738 reusable_cache_entry_exists(path) || path_entry_exists(&reusable_worktree_lock_path(path))
1739}
1740
1741fn path_entry_exists(path: &Path) -> bool {
1742 std::fs::symlink_metadata(path).is_ok()
1743}
1744
1745fn remove_reusable_cache_entry_locked(repo_root: &Path, path: &Path) -> std::io::Result<bool> {
1748 let existed = reusable_cache_entry_exists(path);
1749 ensure_cache_entry_is_owned(path)?;
1750 if trusted_worktree_admin_dir(repo_root, path).is_some() {
1751 unregister_worktree_checked(repo_root, path)?;
1752 }
1753 remove_dir_if_exists(path)?;
1754 remove_file_if_exists(&reusable_worktree_sha_path(path))?;
1755 remove_file_if_exists(&reusable_worktree_last_used_path(path))?;
1756 Ok(existed)
1757}
1758
1759enum CacheEntryOwnership {
1760 Owned,
1761 #[cfg_attr(
1762 not(unix),
1763 expect(dead_code, reason = "only the Unix uid probe constructs this variant")
1764 )]
1765 Unowned(PathBuf),
1766}
1767
1768#[cfg(unix)]
1769fn cache_entry_ownership(path: &Path) -> std::io::Result<CacheEntryOwnership> {
1770 use std::os::unix::fs::MetadataExt as _;
1771
1772 let effective_uid = rustix::process::geteuid().as_raw();
1773 for entry in [
1774 path.to_path_buf(),
1775 reusable_worktree_sha_path(path),
1776 reusable_worktree_last_used_path(path),
1777 ] {
1778 let metadata = match std::fs::symlink_metadata(&entry) {
1779 Ok(metadata) => metadata,
1780 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1781 Err(error) => return Err(error),
1782 };
1783 if metadata.uid() != effective_uid {
1784 return Ok(CacheEntryOwnership::Unowned(entry));
1785 }
1786 }
1787 Ok(CacheEntryOwnership::Owned)
1788}
1789
1790#[cfg(not(unix))]
1791#[expect(
1792 clippy::unnecessary_wraps,
1793 reason = "shared cross-platform signature; the Unix ownership check is fallible, non-Unix has no POSIX owner to verify"
1794)]
1795fn cache_entry_ownership(_path: &Path) -> std::io::Result<CacheEntryOwnership> {
1796 Ok(CacheEntryOwnership::Owned)
1797}
1798
1799fn ensure_cache_entry_is_owned(path: &Path) -> std::io::Result<()> {
1800 match cache_entry_ownership(path)? {
1801 CacheEntryOwnership::Owned => Ok(()),
1802 CacheEntryOwnership::Unowned(entry) => Err(std::io::Error::new(
1803 std::io::ErrorKind::PermissionDenied,
1804 format!(
1805 "refusing to remove unowned audit cache entry `{}`",
1806 entry.display()
1807 ),
1808 )),
1809 }
1810}
1811
1812fn remove_dir_if_exists(path: &Path) -> std::io::Result<()> {
1813 match std::fs::remove_dir_all(path) {
1814 Ok(()) => Ok(()),
1815 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1816 Err(err) => Err(err),
1817 }
1818}
1819
1820fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
1821 match std::fs::remove_file(path) {
1822 Ok(()) => Ok(()),
1823 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1824 Err(err) => Err(err),
1825 }
1826}
1827
1828pub fn unregister_worktree(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1838 unregister_worktree_checked(repo_root, path)
1839}
1840
1841fn unregister_worktree_checked(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1842 if !path.exists() {
1843 return Ok(());
1844 }
1845 let gitfile = path.join(".git");
1846 let metadata = std::fs::symlink_metadata(&gitfile)?;
1847 if !metadata_is_regular_file(&metadata) {
1848 return Err(std::io::Error::new(
1849 std::io::ErrorKind::InvalidData,
1850 "refusing to deregister through a non-file audit worktree .git entry",
1851 ));
1852 }
1853 let contents = std::fs::read_to_string(&gitfile)?;
1854 if contents == UNREGISTERED_GITDIR_STUB {
1855 return Ok(());
1856 }
1857 let Some(admin_dir) = trusted_worktree_admin_dir(repo_root, path) else {
1858 return Err(std::io::Error::new(
1859 std::io::ErrorKind::InvalidData,
1860 "refusing to deregister an unverified audit worktree admin entry",
1861 ));
1862 };
1863 remove_dir_if_exists(&admin_dir)?;
1864 write_git_stub_safely(&gitfile)
1865}
1866
1867fn repair_unregistered_git_stub(path: &Path) -> bool {
1872 let gitfile = path.join(".git");
1873 let Ok(metadata) = std::fs::symlink_metadata(&gitfile) else {
1874 return write_git_stub_safely(&gitfile).is_ok();
1875 };
1876 if !metadata_is_regular_file(&metadata) {
1877 return false;
1878 }
1879 std::fs::read_to_string(&gitfile).is_ok_and(|contents| contents == UNREGISTERED_GITDIR_STUB)
1880}
1881
1882fn write_git_stub_safely(gitfile: &Path) -> std::io::Result<()> {
1883 let mut options = std::fs::OpenOptions::new();
1884 options.write(true).truncate(true);
1885 match std::fs::symlink_metadata(gitfile) {
1886 Ok(metadata) if metadata_is_regular_file(&metadata) => {}
1887 Ok(_) => {
1888 return Err(std::io::Error::new(
1889 std::io::ErrorKind::InvalidData,
1890 "refusing to replace non-file audit worktree .git entry",
1891 ));
1892 }
1893 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1894 options.create_new(true);
1895 }
1896 Err(error) => return Err(error),
1897 }
1898 let mut file = options.open(gitfile)?;
1899 file.write_all(UNREGISTERED_GITDIR_STUB.as_bytes())?;
1900 file.sync_all()
1901}
1902
1903fn trusted_worktree_admin_dir(repo_root: &Path, path: &Path) -> Option<PathBuf> {
1904 let gitfile = path.join(".git");
1905 let metadata = std::fs::symlink_metadata(&gitfile).ok()?;
1906 if !metadata_is_regular_file(&metadata) {
1907 return None;
1908 }
1909 let contents = std::fs::read_to_string(&gitfile).ok()?;
1910 let admin_dir = parse_worktree_gitdir(&contents)?;
1911 if !is_fallow_admin_dir(&admin_dir) {
1912 return None;
1913 }
1914 let common_dir = fallow_engine::changed_files::resolve_git_common_dir(repo_root).ok()?;
1915 let worktrees_dir = dunce::canonicalize(common_dir.join("worktrees")).ok()?;
1916 let admin_parent = dunce::canonicalize(admin_dir.parent()?).ok()?;
1917 if admin_parent != worktrees_dir {
1918 return None;
1919 }
1920 let backlink = std::fs::read_to_string(admin_dir.join("gitdir")).ok()?;
1921 let expected_gitfile = dunce::canonicalize(&gitfile).ok()?;
1922 let actual_gitfile = dunce::canonicalize(Path::new(backlink.trim())).ok()?;
1923 (actual_gitfile == expected_gitfile).then_some(admin_dir)
1924}
1925
1926fn parse_worktree_gitdir(contents: &str) -> Option<PathBuf> {
1928 contents
1929 .lines()
1930 .find_map(|line| line.trim().strip_prefix("gitdir:"))
1931 .map(|rest| PathBuf::from(rest.trim()))
1932}
1933
1934fn is_fallow_admin_dir(admin_dir: &Path) -> bool {
1937 admin_dir
1938 .file_name()
1939 .and_then(|name| name.to_str())
1940 .is_some_and(|name| name.starts_with("fallow-audit-base-"))
1941}
1942
1943pub fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
1944 let mut command = Command::new("git");
1945 command.args(["rev-parse", rev]).current_dir(root);
1946 clear_ambient_git_env(&mut command);
1947 let output = command.output().ok()?;
1948 if !output.status.success() {
1949 return None;
1950 }
1951 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1952}
1953
1954pub fn git_toplevel(root: &Path) -> Option<PathBuf> {
1955 let mut command = Command::new("git");
1956 command
1957 .args(["rev-parse", "--show-toplevel"])
1958 .current_dir(root);
1959 clear_ambient_git_env(&mut command);
1960 let output = command.output().ok()?;
1961 if !output.status.success() {
1962 return None;
1963 }
1964 let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1965 Some(dunce::canonicalize(&path).unwrap_or(path))
1966}
1967
1968fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
1969 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1970 return false;
1971 };
1972 worktrees.iter().any(|worktree| paths_equal(worktree, path))
1973}
1974
1975pub fn paths_equal(left: &Path, right: &Path) -> bool {
1976 if left == right {
1977 return true;
1978 }
1979 match (dunce::canonicalize(left), dunce::canonicalize(right)) {
1980 (Ok(left), Ok(right)) => left == right,
1981 _ => false,
1982 }
1983}
1984
1985pub fn remove_audit_worktree(repo_root: &Path, path: &Path) {
1986 let mut command = Command::new("git");
1987 command
1988 .args([
1989 "worktree",
1990 "remove",
1991 "--force",
1992 path.to_string_lossy().as_ref(),
1993 ])
1994 .current_dir(repo_root);
1995 clear_ambient_git_env(&mut command);
1996 match crate::signal::scoped_child::output(&mut command) {
1997 Ok(output) => {
1998 if !output.status.success() && path.exists() {
1999 let stderr = String::from_utf8_lossy(&output.stderr);
2000 tracing::warn!(
2001 path = %path.display(),
2002 stderr = %stderr.trim(),
2003 "git worktree remove failed; the directory remains and may leak",
2004 );
2005 }
2006 }
2007 Err(err) => {
2008 tracing::warn!(
2009 path = %path.display(),
2010 error = %err,
2011 "git worktree remove subprocess failed to spawn",
2012 );
2013 }
2014 }
2015}
2016
2017pub fn sweep_orphan_audit_worktrees(repo_root: &Path) {
2018 sweep_orphan_audit_worktrees_in(repo_root, &std::env::temp_dir());
2019}
2020
2021pub fn sweep_orphan_audit_worktrees_in(repo_root: &Path, temp_root: &Path) {
2027 if deregister_legacy_orphan_worktrees(repo_root) {
2032 let mut command = Command::new("git");
2033 command
2034 .args(["worktree", "prune", "--expire=now"])
2035 .current_dir(repo_root);
2036 clear_ambient_git_env(&mut command);
2037 let _ = command.output();
2038 }
2039
2040 for path in scan_non_reusable_orphan_paths(temp_root) {
2044 let _ = std::fs::remove_dir_all(&path);
2045 }
2046}
2047
2048fn deregister_legacy_orphan_worktrees(repo_root: &Path) -> bool {
2051 let Some(worktrees) = list_audit_worktrees(repo_root) else {
2052 return false;
2053 };
2054 let mut removed_any = false;
2055 for path in worktrees {
2056 if !is_fallow_audit_worktree_path(&path)
2057 || is_reusable_audit_worktree_path(&path)
2058 || audit_worktree_process_is_alive(&path)
2059 {
2060 continue;
2061 }
2062 remove_audit_worktree(repo_root, &path);
2063 let _ = std::fs::remove_dir_all(&path);
2064 removed_any = true;
2065 }
2066 removed_any
2067}
2068
2069fn scan_non_reusable_orphan_paths(temp: &Path) -> Vec<PathBuf> {
2072 let Ok(entries) = std::fs::read_dir(temp) else {
2073 return Vec::new();
2074 };
2075 let mut paths = Vec::new();
2076 for entry in entries.flatten() {
2077 let name = entry.file_name();
2078 let Some(name) = name.to_str() else {
2079 continue;
2080 };
2081 let Some(pid) = audit_worktree_pid(name) else {
2082 continue;
2083 };
2084 if process_is_alive(pid) || !entry.path().is_dir() {
2085 continue;
2086 }
2087 paths.push(temp.join(name));
2088 }
2089 paths
2090}
2091
2092pub fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
2093 let mut command = Command::new("git");
2094 command
2095 .args(["worktree", "list", "--porcelain"])
2096 .current_dir(repo_root);
2097 clear_ambient_git_env(&mut command);
2098 let output = command.output().ok()?;
2099 if !output.status.success() {
2100 return None;
2101 }
2102 Some(parse_worktree_list(&String::from_utf8_lossy(
2103 &output.stdout,
2104 )))
2105}
2106
2107pub fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
2108 output
2109 .lines()
2110 .filter_map(|line| line.strip_prefix("worktree "))
2111 .map(PathBuf::from)
2112 .filter(|path| is_fallow_audit_worktree_path(path))
2113 .collect()
2114}
2115
2116pub fn is_fallow_audit_worktree_path(path: &Path) -> bool {
2117 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
2118 return false;
2119 };
2120 name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
2121}
2122
2123pub fn is_reusable_audit_worktree_path(path: &Path) -> bool {
2124 path.file_name()
2125 .and_then(|name| name.to_str())
2126 .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
2127}
2128
2129fn path_is_inside_temp_dir(path: &Path) -> bool {
2130 let temp = std::env::temp_dir();
2131 let simple_path = dunce::simplified(path);
2132 let simple_temp = dunce::simplified(&temp);
2133 if simple_path.starts_with(simple_temp) {
2134 return true;
2135 }
2136 let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
2137 return false;
2138 };
2139 let simple_canonical_temp = dunce::simplified(&canonical_temp);
2140 simple_path.starts_with(simple_canonical_temp)
2141 || std::fs::canonicalize(path).is_ok_and(|canonical_path| {
2142 dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
2143 })
2144}
2145
2146fn audit_worktree_process_is_alive(path: &Path) -> bool {
2147 let Some(pid) = path
2148 .file_name()
2149 .and_then(|name| name.to_str())
2150 .and_then(audit_worktree_pid)
2151 else {
2152 return false;
2153 };
2154 process_is_alive(pid)
2155}
2156
2157pub fn audit_worktree_pid(name: &str) -> Option<u32> {
2158 name.strip_prefix("fallow-audit-base-")?
2159 .split('-')
2160 .next()?
2161 .parse()
2162 .ok()
2163}
2164
2165#[cfg(unix)]
2166pub fn process_is_alive(pid: u32) -> bool {
2167 Command::new("kill")
2168 .args(["-0", &pid.to_string()])
2169 .output()
2170 .is_ok_and(|output| output.status.success())
2171}
2172
2173#[cfg(windows)]
2174pub fn process_is_alive(pid: u32) -> bool {
2175 windows_process::is_alive(pid)
2176}
2177
2178#[cfg(not(any(unix, windows)))]
2179pub fn process_is_alive(_pid: u32) -> bool {
2180 true
2181}
2182
2183#[cfg(windows)]
2184#[allow(
2185 unsafe_code,
2186 reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
2187)]
2188mod windows_process {
2189 use windows_sys::Win32::Foundation::{
2190 CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
2191 WAIT_OBJECT_0,
2192 };
2193 use windows_sys::Win32::System::Threading::{
2194 OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
2195 };
2196
2197 struct ProcessHandle(HANDLE);
2201
2202 impl Drop for ProcessHandle {
2203 fn drop(&mut self) {
2204 unsafe {
2208 CloseHandle(self.0);
2209 }
2210 }
2211 }
2212
2213 pub fn is_alive(pid: u32) -> bool {
2221 let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
2225 if raw.is_null() {
2226 let err = unsafe { GetLastError() };
2229 #[expect(
2230 clippy::match_same_arms,
2231 reason = "named arm documents the cross-session case"
2232 )]
2233 return match err {
2234 ERROR_INVALID_PARAMETER => false,
2235 ERROR_ACCESS_DENIED => true,
2236 _ => true,
2237 };
2238 }
2239 let handle = ProcessHandle(raw);
2240 let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
2243 wait_result != WAIT_OBJECT_0
2244 }
2245}
2246
2247impl Drop for BaseWorktree {
2248 fn drop(&mut self) {
2249 if self.persistent {
2250 return;
2251 }
2252 let _ = std::fs::remove_dir_all(&self.path);
2256 }
2257}
2258
2259#[cfg(test)]
2260mod tests {
2261 use super::*;
2262
2263 #[test]
2269 fn non_reusable_worktree_paths_are_unique_under_concurrency() {
2270 const N: usize = 64;
2271 let barrier = std::sync::Barrier::new(N);
2272 let paths = std::sync::Mutex::new(Vec::with_capacity(N));
2273 std::thread::scope(|s| {
2274 for _ in 0..N {
2275 let barrier = &barrier;
2276 let paths = &paths;
2277 s.spawn(move || {
2278 barrier.wait();
2279 let path = non_reusable_worktree_path().expect("path should build");
2280 paths.lock().unwrap().push(path);
2281 });
2282 }
2283 });
2284 let mut paths = paths.into_inner().unwrap();
2285 assert_eq!(paths.len(), N);
2286 paths.sort();
2287 paths.dedup();
2288 assert_eq!(paths.len(), N, "non-reusable worktree paths collided");
2289 }
2290
2291 #[test]
2293 fn non_reusable_worktree_path_pid_is_parseable() {
2294 let path = non_reusable_worktree_path().expect("path should build");
2295 let name = path.file_name().unwrap().to_str().unwrap();
2296 assert!(is_fallow_audit_worktree_path(&path));
2297 assert!(!is_reusable_audit_worktree_path(&path));
2298 assert_eq!(audit_worktree_pid(name), Some(std::process::id()));
2299 }
2300
2301 #[test]
2302 fn directory_size_bytes_sums_regular_files_recursively() {
2303 let temp = tempfile::TempDir::new().expect("temp dir should be created");
2304 let root = temp.path().join("cache");
2305 std::fs::create_dir_all(root.join("node_modules/dep")).expect("tree should be created");
2306 std::fs::write(root.join("a.txt"), vec![0u8; 10]).expect("file should be written");
2307 std::fs::write(root.join("node_modules/dep/b.js"), vec![0u8; 32])
2308 .expect("nested file should be written");
2309 std::fs::write(root.join(".gitignore"), "node_modules\n")
2312 .expect("gitignore should be written");
2313
2314 let size = directory_size_bytes(&root).expect("size walk should succeed");
2315 assert_eq!(size, 10 + 32 + "node_modules\n".len() as u64);
2316 assert_eq!(
2317 directory_size_bytes(&temp.path().join("missing")),
2318 None,
2319 "an absent directory reports no size",
2320 );
2321 }
2322
2323 #[cfg(unix)]
2324 #[test]
2325 fn directory_size_bytes_never_follows_symlinks() {
2326 let temp = tempfile::TempDir::new().expect("temp dir should be created");
2327 let root = temp.path().join("cache");
2328 let outside = temp.path().join("outside");
2329 std::fs::create_dir_all(&root).expect("root should be created");
2330 std::fs::create_dir_all(&outside).expect("outside dir should be created");
2331 std::fs::write(outside.join("big.bin"), vec![0u8; 4096])
2332 .expect("outside file should be written");
2333 std::os::unix::fs::symlink(&outside, root.join("link-dir"))
2334 .expect("dir symlink should be created");
2335 std::os::unix::fs::symlink(outside.join("big.bin"), root.join("link-file"))
2336 .expect("file symlink should be created");
2337
2338 assert_eq!(
2339 directory_size_bytes(&root),
2340 Some(0),
2341 "symlinked directories and files must not be traversed or counted",
2342 );
2343 }
2344
2345 #[cfg(unix)]
2346 #[test]
2347 fn cache_sidecar_open_does_not_follow_symlinks() {
2348 let temp = tempfile::TempDir::new().expect("temp dir should be created");
2349 let victim = temp.path().join("victim");
2350 let sidecar = temp.path().join("cache.lock");
2351 std::fs::write(&victim, "unchanged\n").expect("victim should be written");
2352 std::os::unix::fs::symlink(&victim, &sidecar).expect("sidecar symlink should be created");
2353
2354 assert!(open_or_create_owned_sidecar(&sidecar).is_err());
2355 assert_eq!(
2356 std::fs::read_to_string(victim).expect("victim should remain readable"),
2357 "unchanged\n",
2358 );
2359 }
2360}