1use std::io::{Read, Write};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{Duration, SystemTime};
6
7use fallow_engine::changed_files::clear_ambient_git_env;
8use rustc_hash::FxHashSet;
9use xxhash_rust::xxh3::xxh3_64;
10
11use crate::report::plural;
12
13pub struct BaseWorktree {
14 path: PathBuf,
15 persistent: bool,
16 _reusable_lock: Option<ReusableWorktreeLock>,
17}
18
19impl BaseWorktree {
20 pub fn create(repo_root: &Path, base_ref: &str, base_sha: Option<&str>) -> Option<Self> {
21 sweep_orphan_audit_worktrees(repo_root);
22 if let Some(base_sha) = base_sha
23 && let Some(worktree) = Self::reuse_or_create(repo_root, base_sha)
24 {
25 return Some(worktree);
26 }
27 let path = non_reusable_worktree_path()?;
28 let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
29 if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
30 repo_root,
31 guard.path(),
32 base_ref,
33 ) {
34 tracing::debug!(
35 base_ref,
36 error = %error,
37 "could not materialize non-reusable audit base worktree",
38 );
39 return None;
40 }
41 if let Err(error) = unregister_worktree(repo_root, guard.path()) {
46 tracing::debug!(
47 path = %guard.path().display(),
48 error = %error,
49 "could not deregister non-reusable audit base worktree",
50 );
51 return None;
52 }
53 guard.defuse();
54 drop(guard);
55 let worktree = Self {
56 path,
57 persistent: false,
58 _reusable_lock: None,
59 };
60 materialize_base_dependency_context(repo_root, worktree.path());
61 Some(worktree)
62 }
63
64 pub fn reuse_or_create(repo_root: &Path, base_sha: &str) -> Option<Self> {
65 let path = reusable_audit_worktree_path(repo_root);
66 let reusable_lock = ReusableWorktreeLock::try_acquire(&path)?;
67
68 if reusable_audit_worktree_is_ready(&path, base_sha)
69 || try_migrate_registered_current_cache(repo_root, &path, base_sha)
70 {
71 let worktree = Self {
72 path,
73 persistent: true,
74 _reusable_lock: Some(reusable_lock),
75 };
76 materialize_base_dependency_context(repo_root, worktree.path());
77 touch_last_used(worktree.path());
78 return Some(worktree);
79 }
80
81 if let Err(error) = remove_file_if_exists(&reusable_worktree_sha_path(&path)) {
82 tracing::debug!(
83 path = %path.display(),
84 error = %error,
85 "could not clear reusable audit worktree readiness before rebuild",
86 );
87 return None;
88 }
89 if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
90 tracing::debug!(
91 path = %path.display(),
92 error = %error,
93 "could not remove stale reusable audit worktree before rebuild",
94 );
95 return None;
96 }
97 let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
98 if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
99 repo_root,
100 guard.path(),
101 base_sha,
102 ) {
103 tracing::debug!(
104 base_sha,
105 error = %error,
106 "could not materialize reusable audit base worktree",
107 );
108 return None;
109 }
110 if let Err(error) = unregister_worktree_checked(repo_root, guard.path()) {
115 tracing::debug!(
116 path = %guard.path().display(),
117 error = %error,
118 "could not deregister reusable audit base worktree",
119 );
120 return None;
121 }
122 guard.defuse();
123 drop(guard);
124 let readiness_published = write_reusable_sha(&path, base_sha).is_ok();
125
126 let worktree = Self {
127 path,
128 persistent: true,
129 _reusable_lock: Some(reusable_lock),
130 };
131 materialize_base_dependency_context(repo_root, worktree.path());
132 if readiness_published {
133 touch_last_used(worktree.path());
134 }
135 Some(worktree)
136 }
137
138 pub fn path(&self) -> &Path {
139 &self.path
140 }
141}
142
143fn non_reusable_worktree_path() -> Option<PathBuf> {
153 static SEQ: AtomicU64 = AtomicU64::new(0);
154 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
155 let nanos = SystemTime::now()
156 .duration_since(SystemTime::UNIX_EPOCH)
157 .ok()?
158 .as_nanos();
159 Some(std::env::temp_dir().join(format!(
160 "fallow-audit-base-{}-{nanos}-{seq}",
161 std::process::id()
162 )))
163}
164
165pub struct WorktreeCleanupGuard<'a> {
177 repo_root: PathBuf,
178 path: &'a Path,
179 armed: bool,
180}
181
182impl<'a> WorktreeCleanupGuard<'a> {
183 pub fn new(repo_root: &Path, path: &'a Path) -> Self {
184 Self {
185 repo_root: repo_root.to_path_buf(),
186 path,
187 armed: true,
188 }
189 }
190
191 pub fn path(&self) -> &Path {
192 self.path
193 }
194
195 pub fn defuse(&mut self) {
198 self.armed = false;
199 }
200}
201
202impl Drop for WorktreeCleanupGuard<'_> {
203 fn drop(&mut self) {
204 if self.armed {
205 remove_audit_worktree(&self.repo_root, self.path);
206 let _ = std::fs::remove_dir_all(self.path);
207 }
208 }
209}
210
211pub struct ReusableWorktreeLock {
217 file: std::fs::File,
218}
219
220impl ReusableWorktreeLock {
221 pub fn try_acquire(reusable_path: &Path) -> Option<Self> {
222 let lock_path = reusable_worktree_lock_path(reusable_path);
223 let file = open_or_create_owned_sidecar(&lock_path).ok()?;
224 match file.try_lock() {
225 Ok(()) => Some(Self { file }),
226 Err(std::fs::TryLockError::WouldBlock) => {
227 tracing::debug!(
228 path = %lock_path.display(),
229 "reusable audit worktree lock contended; falling back to non-reusable worktree",
230 );
231 None
232 }
233 Err(std::fs::TryLockError::Error(err)) => {
234 tracing::debug!(
235 path = %lock_path.display(),
236 error = %err,
237 "could not acquire reusable audit worktree lock; falling back to non-reusable worktree",
238 );
239 None
240 }
241 }
242 }
243}
244
245impl Drop for ReusableWorktreeLock {
246 fn drop(&mut self) {
247 let _ = self.file.unlock();
248 }
249}
250
251pub fn reusable_worktree_lock_path(reusable_path: &Path) -> PathBuf {
252 sidecar_path(reusable_path, REUSABLE_LOCK_SUFFIX)
253}
254
255fn sidecar_path(reusable_path: &Path, suffix: &str) -> PathBuf {
259 let mut name = reusable_path
260 .file_name()
261 .map(std::ffi::OsString::from)
262 .unwrap_or_default();
263 name.push(suffix);
264 reusable_path
265 .parent()
266 .map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
267}
268
269pub fn reusable_worktree_sha_path(reusable_path: &Path) -> PathBuf {
273 sidecar_path(reusable_path, REUSABLE_SHA_SUFFIX)
274}
275
276fn write_reusable_sha(reusable_path: &Path, base_sha: &str) -> std::io::Result<()> {
279 static SEQ: AtomicU64 = AtomicU64::new(0);
280
281 let sha_path = reusable_worktree_sha_path(reusable_path);
282 let sequence = SEQ.fetch_add(1, Ordering::Relaxed);
283 let temp_path = sidecar_path(
284 reusable_path,
285 &format!(
286 "{REUSABLE_SHA_SUFFIX}.tmp-{}-{sequence}",
287 std::process::id()
288 ),
289 );
290 let result = (|| {
291 let mut options = std::fs::OpenOptions::new();
292 options.create_new(true).write(true);
293 #[cfg(unix)]
294 {
295 use std::os::unix::fs::OpenOptionsExt as _;
296 options.mode(0o600);
297 }
298 let mut file = options.open(&temp_path)?;
299 file.write_all(format!("{base_sha}\n").as_bytes())?;
300 file.sync_all()?;
301 std::fs::rename(&temp_path, &sha_path)
302 })();
303 if let Err(err) = &result {
304 let _ = std::fs::remove_file(&temp_path);
305 tracing::debug!(
306 path = %sha_path.display(),
307 error = %err,
308 "failed to write reusable audit worktree .sha sidecar; next run will rebuild",
309 );
310 }
311 result
312}
313
314const DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS: u32 = 30;
316
317const AUDIT_CACHE_MAX_AGE_ENV: &str = "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS";
319
320const REUSABLE_LAST_USED_SUFFIX: &str = ".last-used";
322
323const REUSABLE_SHA_SUFFIX: &str = ".sha";
325
326const REUSABLE_LOCK_SUFFIX: &str = ".lock";
328
329const UNREGISTERED_GITDIR_STUB: &str = "gitdir: fallow-audit-unregistered\n";
340
341pub fn reusable_worktree_last_used_path(reusable_path: &Path) -> PathBuf {
346 sidecar_path(reusable_path, REUSABLE_LAST_USED_SUFFIX)
347}
348
349pub fn touch_last_used(reusable_path: &Path) {
357 let last_used = reusable_worktree_last_used_path(reusable_path);
358 let result = open_or_create_owned_sidecar(&last_used)
359 .and_then(|file| file.set_modified(SystemTime::now()));
360 if let Err(err) = result {
361 tracing::warn!(
362 path = %last_used.display(),
363 error = %err,
364 "failed to touch reusable audit worktree sidecar; staleness signal may not update",
365 );
366 }
367}
368
369fn open_or_create_owned_sidecar(path: &Path) -> std::io::Result<std::fs::File> {
370 match std::fs::symlink_metadata(path) {
371 Ok(metadata) if sidecar_metadata_is_trusted(&metadata) => {
372 std::fs::OpenOptions::new().write(true).open(path)
373 }
374 Ok(_) => Err(std::io::Error::new(
375 std::io::ErrorKind::PermissionDenied,
376 "refusing to open an untrusted audit cache sidecar",
377 )),
378 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
379 let mut options = std::fs::OpenOptions::new();
380 options.create_new(true).write(true);
381 #[cfg(unix)]
382 {
383 use std::os::unix::fs::OpenOptionsExt as _;
384 options.mode(0o600);
385 }
386 options.open(path)
387 }
388 Err(error) => Err(error),
389 }
390}
391
392#[cfg(unix)]
393fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
394 use std::os::unix::fs::MetadataExt as _;
395
396 metadata_is_regular_file(metadata) && metadata.uid() == rustix::process::geteuid().as_raw()
397}
398
399#[cfg(not(unix))]
400fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
401 metadata_is_regular_file(metadata)
402}
403
404#[expect(
405 clippy::filetype_is_file,
406 reason = "security-sensitive sidecars and gitfiles must be regular files, not arbitrary non-directories"
407)]
408fn metadata_is_regular_file(metadata: &std::fs::Metadata) -> bool {
409 metadata.file_type().is_file()
410}
411
412pub fn resolve_cache_max_age_with_options(
419 root: &Path,
420 config_path: Option<&PathBuf>,
421 allow_remote_extends: bool,
422) -> Option<Duration> {
423 if let Ok(raw) = std::env::var(AUDIT_CACHE_MAX_AGE_ENV) {
424 if let Ok(days) = raw.trim().parse::<u32>() {
425 return days_to_duration(days);
426 }
427 tracing::debug!(
428 value = %raw,
429 "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS is not a valid u32; falling back to config/default",
430 );
431 }
432 if let Some(days) = load_audit_config(root, config_path, allow_remote_extends)
433 .and_then(|c| c.cache_max_age_days)
434 {
435 return days_to_duration(days);
436 }
437 days_to_duration(DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS)
438}
439
440pub fn days_to_duration(days: u32) -> Option<Duration> {
441 if days == 0 {
442 return None;
443 }
444 Some(Duration::from_secs(u64::from(days) * 86_400))
445}
446
447fn load_audit_config(
451 root: &Path,
452 config_path: Option<&PathBuf>,
453 allow_remote_extends: bool,
454) -> Option<fallow_config::AuditConfig> {
455 let options = fallow_config::ConfigLoadOptions {
456 allow_remote_extends,
457 };
458 if let Some(path) = config_path {
459 return fallow_config::FallowConfig::load_with_options(path, options)
460 .ok()
461 .map(|config| config.audit);
462 }
463 fallow_config::FallowConfig::find_and_load_with_options(root, options)
464 .ok()
465 .flatten()
466 .map(|(config, _path)| config.audit)
467}
468
469pub fn sweep_old_reusable_caches(repo_root: &Path, max_age: Option<Duration>, quiet: bool) {
500 if deregister_legacy_reusable_caches(repo_root) {
506 let mut command = Command::new("git");
507 command
508 .args(["worktree", "prune", "--expire=now"])
509 .current_dir(repo_root);
510 clear_ambient_git_env(&mut command);
511 let _ = command.output();
512 }
513
514 let mut paths = vec![reusable_audit_worktree_path(repo_root)];
518 paths.extend(scan_legacy_reusable_cache_paths(repo_root));
519 paths.sort();
520 paths.dedup();
521 let now = SystemTime::now();
522 let mut removed: u32 = 0;
523 for path in paths {
524 if reclaim_reusable_cache_entry(repo_root, &path, max_age, now) {
525 removed += 1;
526 }
527 }
528 if removed == 0 {
529 return;
530 }
531 tracing::info!(
532 count = removed,
533 "reclaimed stale audit base-snapshot caches",
534 );
535 if !quiet {
536 let s = plural(removed as usize);
537 let _ = writeln!(
538 std::io::stderr(),
539 "fallow: reclaimed {removed} stale base-snapshot cache{s}",
540 );
541 }
542}
543
544fn deregister_legacy_reusable_caches(repo_root: &Path) -> bool {
549 let Some(worktrees) = list_audit_worktrees(repo_root) else {
550 return false;
551 };
552 let mut deregistered = false;
553 for path in worktrees {
554 if !is_reusable_audit_worktree_path(&path) {
555 continue;
556 }
557 let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
558 continue;
559 };
560 if !audit_worktree_is_registered(repo_root, &path) {
561 continue;
562 }
563 let is_current_path = paths_equal(&path, &reusable_audit_worktree_path(repo_root));
564 let head = is_current_path
565 .then(|| legacy_reusable_sha(&path))
566 .flatten();
567 if unregister_worktree_checked(repo_root, &path).is_err() {
568 continue;
569 }
570 if is_current_path {
571 if let Some(head) = head {
572 let _ = write_reusable_sha(&path, &head);
573 }
574 } else if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
575 tracing::warn!(
576 path = %path.display(),
577 error = %error,
578 "failed to remove released SHA-keyed audit cache",
579 );
580 }
581 deregistered = true;
582 }
583 deregistered
584}
585
586fn legacy_reusable_sha(path: &Path) -> Option<String> {
590 if reusable_worktree_sha_path(path).exists()
591 || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
592 {
593 return None;
594 }
595 git_rev_parse(path, "HEAD")
596}
597
598fn scan_legacy_reusable_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
603 let Some(prefix) = legacy_reusable_cache_repo_prefix(repo_root) else {
604 return Vec::new();
605 };
606 scan_cache_paths_with_hex_suffix(&prefix)
607}
608
609fn scan_root_owned_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
610 let Some(prefix) = root_owned_cache_repo_prefix(repo_root) else {
611 return Vec::new();
612 };
613 scan_cache_paths_with_hex_suffix(&prefix)
614}
615
616fn scan_cache_paths_with_hex_suffix(prefix: &str) -> Vec<PathBuf> {
617 let temp = std::env::temp_dir();
618 let Ok(entries) = std::fs::read_dir(&temp) else {
619 return Vec::new();
620 };
621 let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
622 let mut paths = Vec::new();
623 for entry in entries.flatten() {
624 let name = entry.file_name();
625 let Some(name) = name.to_str() else {
626 continue;
627 };
628 let cache_name = strip_cache_sidecar_suffix(name);
629 let Some(hash_suffix) = cache_name.strip_prefix(prefix) else {
630 continue;
631 };
632 if hash_suffix.len() != 16 || !hash_suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
633 continue;
634 }
635 let path = temp.join(cache_name);
636 if seen.insert(path.clone()) {
637 paths.push(path);
638 }
639 }
640 paths
641}
642
643fn strip_cache_sidecar_suffix(name: &str) -> &str {
647 for suffix in [
648 REUSABLE_LAST_USED_SUFFIX,
649 REUSABLE_SHA_SUFFIX,
650 REUSABLE_LOCK_SUFFIX,
651 ] {
652 if let Some(stripped) = name.strip_suffix(suffix) {
653 return stripped;
654 }
655 }
656 name
657}
658
659fn reclaim_reusable_cache_entry(
662 repo_root: &Path,
663 path: &Path,
664 max_age: Option<Duration>,
665 now: SystemTime,
666) -> bool {
667 if !path.exists() {
675 return reclaim_orphan_cache_entry(repo_root, path);
676 }
677 let Some(max_age) = max_age else {
678 return false;
679 };
680 reclaim_aged_cache_entry(repo_root, path, max_age, now)
681}
682
683fn reclaim_orphan_cache_entry(repo_root: &Path, path: &Path) -> bool {
689 let Some(_lock) = ReusableWorktreeLock::try_acquire(path) else {
690 return false;
691 };
692 if path.exists() {
695 return false;
696 }
697 remove_reusable_cache_entry_locked(repo_root, path).unwrap_or(false)
698}
699
700fn reclaim_aged_cache_entry(
706 repo_root: &Path,
707 path: &Path,
708 max_age: Duration,
709 now: SystemTime,
710) -> bool {
711 let sidecar = reusable_worktree_last_used_path(path);
712 let sidecar_mtime = std::fs::metadata(&sidecar)
713 .ok()
714 .and_then(|m| m.modified().ok());
715 let Some(mtime) = sidecar_mtime else {
716 touch_last_used(path);
717 return false;
718 };
719 let Ok(age) = now.duration_since(mtime) else {
720 return false;
721 };
722 if age < max_age {
723 return false;
724 }
725 let Some(_lock) = ReusableWorktreeLock::try_acquire(path) else {
726 return false;
727 };
728 match remove_reusable_cache_entry_locked(repo_root, path) {
729 Ok(removed) => removed,
730 Err(err) => {
731 tracing::warn!(
732 path = %path.display(),
733 error = %err,
734 "failed to remove stale reusable audit worktree entry; entry may leak",
735 );
736 false
737 }
738 }
739}
740
741pub fn canonical_root_hash(root: &Path) -> u64 {
742 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
743 xxh3_64(&path_identity_bytes(&canonical_root))
744}
745
746#[cfg(unix)]
747fn path_identity_bytes(path: &Path) -> Vec<u8> {
748 use std::os::unix::ffi::OsStrExt as _;
749
750 path.as_os_str().as_bytes().to_vec()
751}
752
753#[cfg(windows)]
754fn path_identity_bytes(path: &Path) -> Vec<u8> {
755 use std::os::windows::ffi::OsStrExt as _;
756
757 path.as_os_str()
758 .encode_wide()
759 .flat_map(u16::to_le_bytes)
760 .collect()
761}
762
763#[cfg(not(any(unix, windows)))]
764fn path_identity_bytes(path: &Path) -> Vec<u8> {
765 path.to_string_lossy().as_bytes().to_vec()
766}
767
768pub fn reusable_audit_worktree_path(requested_root: &Path) -> PathBuf {
769 let root_hash = canonical_root_hash(requested_root);
770 let repo_hash = git_toplevel(requested_root)
771 .as_deref()
772 .map_or(root_hash, canonical_root_hash);
773 std::env::temp_dir().join(format!(
774 "fallow-audit-base-cache-{repo_hash:016x}-root-{root_hash:016x}"
775 ))
776}
777
778fn root_owned_cache_repo_prefix(requested_root: &Path) -> Option<String> {
779 let git_root = git_toplevel(requested_root)?;
780 let repo_hash = canonical_root_hash(&git_root);
781 Some(format!("fallow-audit-base-cache-{repo_hash:016x}-root-"))
782}
783
784fn legacy_reusable_cache_repo_prefix(requested_root: &Path) -> Option<String> {
785 let git_root = git_toplevel(requested_root)?;
786 let repo_hash = canonical_root_hash(&git_root);
787 Some(format!("fallow-audit-base-cache-{repo_hash:016x}-"))
788}
789
790#[cfg(test)]
791pub fn legacy_reusable_audit_worktree_path(
792 requested_root: &Path,
793 base_sha: &str,
794) -> Option<PathBuf> {
795 let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
796 Some(std::env::temp_dir().join(format!(
797 "{}{sha_prefix}",
798 legacy_reusable_cache_repo_prefix(requested_root)?
799 )))
800}
801
802fn reusable_audit_worktree_is_ready(path: &Path, base_sha: &str) -> bool {
813 if !reusable_cache_directory_is_trusted(path) {
814 return false;
815 }
816 let recorded = read_reusable_sha(path);
817 if recorded.as_deref() != Some(base_sha) {
818 return false;
819 }
820 repair_unregistered_git_stub(path)
821}
822
823fn read_reusable_sha(path: &Path) -> Option<String> {
824 const MAX_SHA_SIDECAR_BYTES: u64 = 129;
825
826 let sidecar = reusable_worktree_sha_path(path);
827 let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
828 if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_SHA_SIDECAR_BYTES {
829 return None;
830 }
831 let mut contents = String::new();
832 std::fs::File::open(sidecar)
833 .ok()?
834 .take(MAX_SHA_SIDECAR_BYTES)
835 .read_to_string(&mut contents)
836 .ok()?;
837 Some(contents.trim().to_owned())
838}
839
840#[cfg(unix)]
841fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
842 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
843
844 let Ok(metadata) = std::fs::symlink_metadata(path) else {
845 return false;
846 };
847 metadata.file_type().is_dir()
848 && metadata.uid() == rustix::process::geteuid().as_raw()
849 && metadata.permissions().mode().trailing_zeros() >= 6
850}
851
852#[cfg(not(unix))]
853fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
854 std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
855}
856
857fn try_migrate_registered_current_cache(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
863 if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
864 return false;
865 }
866 let head_matches = git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha);
867 if !head_matches || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
868 {
869 return false;
870 }
871 if unregister_worktree_checked(repo_root, path).is_err() {
872 return false;
873 }
874 write_reusable_sha(path, base_sha).is_ok()
875}
876
877#[derive(Debug, Clone, Copy, PartialEq, Eq)]
884pub struct AuditCacheRemovalReport {
885 pub found: usize,
886 pub removed: usize,
887 pub skipped: usize,
888 pub dry_run: bool,
889}
890
891pub fn remove_reusable_audit_caches(
892 requested_root: &Path,
893 dry_run: bool,
894) -> std::io::Result<AuditCacheRemovalReport> {
895 let mut paths = vec![reusable_audit_worktree_path(requested_root)];
896 paths.extend(scan_legacy_reusable_cache_paths(requested_root));
897 if git_toplevel(requested_root).is_some_and(|root| paths_equal(&root, requested_root)) {
898 paths.extend(scan_root_owned_cache_paths(requested_root));
899 }
900 paths.sort();
901 paths.dedup();
902
903 let mut report = AuditCacheRemovalReport {
904 found: 0,
905 removed: 0,
906 skipped: 0,
907 dry_run,
908 };
909 for path in paths {
910 if !reusable_cache_entry_exists(&path) {
911 continue;
912 }
913 report.found += 1;
914 if dry_run {
915 continue;
920 }
921 let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
922 report.skipped += 1;
923 continue;
924 };
925 if remove_reusable_cache_entry_locked(requested_root, &path)? {
926 report.removed += 1;
927 }
928 }
929 Ok(report)
930}
931
932fn reusable_cache_entry_exists(path: &Path) -> bool {
933 path_entry_exists(path)
934 || path_entry_exists(&reusable_worktree_sha_path(path))
935 || path_entry_exists(&reusable_worktree_last_used_path(path))
936}
937
938fn path_entry_exists(path: &Path) -> bool {
939 std::fs::symlink_metadata(path).is_ok()
940}
941
942fn remove_reusable_cache_entry_locked(repo_root: &Path, path: &Path) -> std::io::Result<bool> {
945 let existed = reusable_cache_entry_exists(path);
946 ensure_cache_entry_is_owned(path)?;
947 if trusted_worktree_admin_dir(repo_root, path).is_some() {
948 unregister_worktree_checked(repo_root, path)?;
949 }
950 remove_dir_if_exists(path)?;
951 remove_file_if_exists(&reusable_worktree_sha_path(path))?;
952 remove_file_if_exists(&reusable_worktree_last_used_path(path))?;
953 Ok(existed)
954}
955
956#[cfg(unix)]
957fn ensure_cache_entry_is_owned(path: &Path) -> std::io::Result<()> {
958 use std::os::unix::fs::MetadataExt as _;
959
960 let effective_uid = rustix::process::geteuid().as_raw();
961 for entry in [
962 path.to_path_buf(),
963 reusable_worktree_sha_path(path),
964 reusable_worktree_last_used_path(path),
965 ] {
966 let metadata = match std::fs::symlink_metadata(&entry) {
967 Ok(metadata) => metadata,
968 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
969 Err(error) => return Err(error),
970 };
971 if metadata.uid() != effective_uid {
972 return Err(std::io::Error::new(
973 std::io::ErrorKind::PermissionDenied,
974 format!(
975 "refusing to remove unowned audit cache entry `{}`",
976 entry.display()
977 ),
978 ));
979 }
980 }
981 Ok(())
982}
983
984#[cfg(not(unix))]
985#[expect(
986 clippy::unnecessary_wraps,
987 reason = "shared cross-platform signature; the Unix ownership check is fallible, non-Unix has no POSIX owner to verify"
988)]
989fn ensure_cache_entry_is_owned(_path: &Path) -> std::io::Result<()> {
990 Ok(())
991}
992
993fn remove_dir_if_exists(path: &Path) -> std::io::Result<()> {
994 match std::fs::remove_dir_all(path) {
995 Ok(()) => Ok(()),
996 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
997 Err(err) => Err(err),
998 }
999}
1000
1001fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
1002 match std::fs::remove_file(path) {
1003 Ok(()) => Ok(()),
1004 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1005 Err(err) => Err(err),
1006 }
1007}
1008
1009pub fn unregister_worktree(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1019 unregister_worktree_checked(repo_root, path)
1020}
1021
1022fn unregister_worktree_checked(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1023 if !path.exists() {
1024 return Ok(());
1025 }
1026 let gitfile = path.join(".git");
1027 let metadata = std::fs::symlink_metadata(&gitfile)?;
1028 if !metadata_is_regular_file(&metadata) {
1029 return Err(std::io::Error::new(
1030 std::io::ErrorKind::InvalidData,
1031 "refusing to deregister through a non-file audit worktree .git entry",
1032 ));
1033 }
1034 let contents = std::fs::read_to_string(&gitfile)?;
1035 if contents == UNREGISTERED_GITDIR_STUB {
1036 return Ok(());
1037 }
1038 let Some(admin_dir) = trusted_worktree_admin_dir(repo_root, path) else {
1039 return Err(std::io::Error::new(
1040 std::io::ErrorKind::InvalidData,
1041 "refusing to deregister an unverified audit worktree admin entry",
1042 ));
1043 };
1044 remove_dir_if_exists(&admin_dir)?;
1045 write_git_stub_safely(&gitfile)
1046}
1047
1048fn repair_unregistered_git_stub(path: &Path) -> bool {
1053 let gitfile = path.join(".git");
1054 let Ok(metadata) = std::fs::symlink_metadata(&gitfile) else {
1055 return write_git_stub_safely(&gitfile).is_ok();
1056 };
1057 if !metadata_is_regular_file(&metadata) {
1058 return false;
1059 }
1060 std::fs::read_to_string(&gitfile).is_ok_and(|contents| contents == UNREGISTERED_GITDIR_STUB)
1061}
1062
1063fn write_git_stub_safely(gitfile: &Path) -> std::io::Result<()> {
1064 let mut options = std::fs::OpenOptions::new();
1065 options.write(true).truncate(true);
1066 match std::fs::symlink_metadata(gitfile) {
1067 Ok(metadata) if metadata_is_regular_file(&metadata) => {}
1068 Ok(_) => {
1069 return Err(std::io::Error::new(
1070 std::io::ErrorKind::InvalidData,
1071 "refusing to replace non-file audit worktree .git entry",
1072 ));
1073 }
1074 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1075 options.create_new(true);
1076 }
1077 Err(error) => return Err(error),
1078 }
1079 let mut file = options.open(gitfile)?;
1080 file.write_all(UNREGISTERED_GITDIR_STUB.as_bytes())?;
1081 file.sync_all()
1082}
1083
1084fn trusted_worktree_admin_dir(repo_root: &Path, path: &Path) -> Option<PathBuf> {
1085 let gitfile = path.join(".git");
1086 let metadata = std::fs::symlink_metadata(&gitfile).ok()?;
1087 if !metadata_is_regular_file(&metadata) {
1088 return None;
1089 }
1090 let contents = std::fs::read_to_string(&gitfile).ok()?;
1091 let admin_dir = parse_worktree_gitdir(&contents)?;
1092 if !is_fallow_admin_dir(&admin_dir) {
1093 return None;
1094 }
1095 let common_dir = fallow_engine::changed_files::resolve_git_common_dir(repo_root).ok()?;
1096 let worktrees_dir = dunce::canonicalize(common_dir.join("worktrees")).ok()?;
1097 let admin_parent = dunce::canonicalize(admin_dir.parent()?).ok()?;
1098 if admin_parent != worktrees_dir {
1099 return None;
1100 }
1101 let backlink = std::fs::read_to_string(admin_dir.join("gitdir")).ok()?;
1102 let expected_gitfile = dunce::canonicalize(&gitfile).ok()?;
1103 let actual_gitfile = dunce::canonicalize(Path::new(backlink.trim())).ok()?;
1104 (actual_gitfile == expected_gitfile).then_some(admin_dir)
1105}
1106
1107fn parse_worktree_gitdir(contents: &str) -> Option<PathBuf> {
1109 contents
1110 .lines()
1111 .find_map(|line| line.trim().strip_prefix("gitdir:"))
1112 .map(|rest| PathBuf::from(rest.trim()))
1113}
1114
1115fn is_fallow_admin_dir(admin_dir: &Path) -> bool {
1118 admin_dir
1119 .file_name()
1120 .and_then(|name| name.to_str())
1121 .is_some_and(|name| name.starts_with("fallow-audit-base-"))
1122}
1123
1124pub fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
1125 let mut command = Command::new("git");
1126 command.args(["rev-parse", rev]).current_dir(root);
1127 clear_ambient_git_env(&mut command);
1128 let output = command.output().ok()?;
1129 if !output.status.success() {
1130 return None;
1131 }
1132 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1133}
1134
1135pub fn git_toplevel(root: &Path) -> Option<PathBuf> {
1136 let mut command = Command::new("git");
1137 command
1138 .args(["rev-parse", "--show-toplevel"])
1139 .current_dir(root);
1140 clear_ambient_git_env(&mut command);
1141 let output = command.output().ok()?;
1142 if !output.status.success() {
1143 return None;
1144 }
1145 let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1146 Some(dunce::canonicalize(&path).unwrap_or(path))
1147}
1148
1149fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
1150 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1151 return false;
1152 };
1153 worktrees.iter().any(|worktree| paths_equal(worktree, path))
1154}
1155
1156pub fn paths_equal(left: &Path, right: &Path) -> bool {
1157 if left == right {
1158 return true;
1159 }
1160 match (dunce::canonicalize(left), dunce::canonicalize(right)) {
1161 (Ok(left), Ok(right)) => left == right,
1162 _ => false,
1163 }
1164}
1165
1166const MATERIALIZED_CONTEXT_DIRS: &[&str] = &["node_modules", ".nuxt", ".astro"];
1183
1184pub fn materialize_base_dependency_context(repo_root: &Path, worktree_path: &Path) {
1185 for &name in MATERIALIZED_CONTEXT_DIRS {
1186 let source = repo_root.join(name);
1187 if !source.is_dir() {
1188 continue;
1189 }
1190
1191 let destination = worktree_path.join(name);
1192 if destination.is_dir() {
1193 continue;
1194 }
1195 if let Ok(metadata) = std::fs::symlink_metadata(&destination) {
1196 if !metadata.file_type().is_symlink() {
1197 continue;
1198 }
1199 let _ = std::fs::remove_file(&destination);
1200 }
1201
1202 let _ = symlink_dependency_dir(&source, &destination);
1203 }
1204}
1205
1206#[cfg(unix)]
1207fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
1208 std::os::unix::fs::symlink(source, destination)
1209}
1210
1211#[cfg(windows)]
1212fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
1213 std::os::windows::fs::symlink_dir(source, destination)
1214}
1215
1216pub fn remove_audit_worktree(repo_root: &Path, path: &Path) {
1217 let mut command = Command::new("git");
1218 command
1219 .args([
1220 "worktree",
1221 "remove",
1222 "--force",
1223 path.to_string_lossy().as_ref(),
1224 ])
1225 .current_dir(repo_root);
1226 clear_ambient_git_env(&mut command);
1227 match crate::signal::scoped_child::output(&mut command) {
1228 Ok(output) => {
1229 if !output.status.success() && path.exists() {
1230 let stderr = String::from_utf8_lossy(&output.stderr);
1231 tracing::warn!(
1232 path = %path.display(),
1233 stderr = %stderr.trim(),
1234 "git worktree remove failed; the directory remains and may leak",
1235 );
1236 }
1237 }
1238 Err(err) => {
1239 tracing::warn!(
1240 path = %path.display(),
1241 error = %err,
1242 "git worktree remove subprocess failed to spawn",
1243 );
1244 }
1245 }
1246}
1247
1248pub fn sweep_orphan_audit_worktrees(repo_root: &Path) {
1249 sweep_orphan_audit_worktrees_in(repo_root, &std::env::temp_dir());
1250}
1251
1252pub fn sweep_orphan_audit_worktrees_in(repo_root: &Path, temp_root: &Path) {
1258 if deregister_legacy_orphan_worktrees(repo_root) {
1263 let mut command = Command::new("git");
1264 command
1265 .args(["worktree", "prune", "--expire=now"])
1266 .current_dir(repo_root);
1267 clear_ambient_git_env(&mut command);
1268 let _ = command.output();
1269 }
1270
1271 for path in scan_non_reusable_orphan_paths(temp_root) {
1275 let _ = std::fs::remove_dir_all(&path);
1276 }
1277}
1278
1279fn deregister_legacy_orphan_worktrees(repo_root: &Path) -> bool {
1282 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1283 return false;
1284 };
1285 let mut removed_any = false;
1286 for path in worktrees {
1287 if !is_fallow_audit_worktree_path(&path)
1288 || is_reusable_audit_worktree_path(&path)
1289 || audit_worktree_process_is_alive(&path)
1290 {
1291 continue;
1292 }
1293 remove_audit_worktree(repo_root, &path);
1294 let _ = std::fs::remove_dir_all(&path);
1295 removed_any = true;
1296 }
1297 removed_any
1298}
1299
1300fn scan_non_reusable_orphan_paths(temp: &Path) -> Vec<PathBuf> {
1303 let Ok(entries) = std::fs::read_dir(temp) else {
1304 return Vec::new();
1305 };
1306 let mut paths = Vec::new();
1307 for entry in entries.flatten() {
1308 let name = entry.file_name();
1309 let Some(name) = name.to_str() else {
1310 continue;
1311 };
1312 let Some(pid) = audit_worktree_pid(name) else {
1313 continue;
1314 };
1315 if process_is_alive(pid) || !entry.path().is_dir() {
1316 continue;
1317 }
1318 paths.push(temp.join(name));
1319 }
1320 paths
1321}
1322
1323pub fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
1324 let mut command = Command::new("git");
1325 command
1326 .args(["worktree", "list", "--porcelain"])
1327 .current_dir(repo_root);
1328 clear_ambient_git_env(&mut command);
1329 let output = command.output().ok()?;
1330 if !output.status.success() {
1331 return None;
1332 }
1333 Some(parse_worktree_list(&String::from_utf8_lossy(
1334 &output.stdout,
1335 )))
1336}
1337
1338pub fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
1339 output
1340 .lines()
1341 .filter_map(|line| line.strip_prefix("worktree "))
1342 .map(PathBuf::from)
1343 .filter(|path| is_fallow_audit_worktree_path(path))
1344 .collect()
1345}
1346
1347pub fn is_fallow_audit_worktree_path(path: &Path) -> bool {
1348 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1349 return false;
1350 };
1351 name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
1352}
1353
1354pub fn is_reusable_audit_worktree_path(path: &Path) -> bool {
1355 path.file_name()
1356 .and_then(|name| name.to_str())
1357 .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
1358}
1359
1360fn path_is_inside_temp_dir(path: &Path) -> bool {
1361 let temp = std::env::temp_dir();
1362 let simple_path = dunce::simplified(path);
1363 let simple_temp = dunce::simplified(&temp);
1364 if simple_path.starts_with(simple_temp) {
1365 return true;
1366 }
1367 let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
1368 return false;
1369 };
1370 let simple_canonical_temp = dunce::simplified(&canonical_temp);
1371 simple_path.starts_with(simple_canonical_temp)
1372 || std::fs::canonicalize(path).is_ok_and(|canonical_path| {
1373 dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
1374 })
1375}
1376
1377fn audit_worktree_process_is_alive(path: &Path) -> bool {
1378 let Some(pid) = path
1379 .file_name()
1380 .and_then(|name| name.to_str())
1381 .and_then(audit_worktree_pid)
1382 else {
1383 return false;
1384 };
1385 process_is_alive(pid)
1386}
1387
1388pub fn audit_worktree_pid(name: &str) -> Option<u32> {
1389 name.strip_prefix("fallow-audit-base-")?
1390 .split('-')
1391 .next()?
1392 .parse()
1393 .ok()
1394}
1395
1396#[cfg(unix)]
1397pub fn process_is_alive(pid: u32) -> bool {
1398 Command::new("kill")
1399 .args(["-0", &pid.to_string()])
1400 .output()
1401 .is_ok_and(|output| output.status.success())
1402}
1403
1404#[cfg(windows)]
1405pub fn process_is_alive(pid: u32) -> bool {
1406 windows_process::is_alive(pid)
1407}
1408
1409#[cfg(not(any(unix, windows)))]
1410pub fn process_is_alive(_pid: u32) -> bool {
1411 true
1412}
1413
1414#[cfg(windows)]
1415#[allow(
1416 unsafe_code,
1417 reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
1418)]
1419mod windows_process {
1420 use windows_sys::Win32::Foundation::{
1421 CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
1422 WAIT_OBJECT_0,
1423 };
1424 use windows_sys::Win32::System::Threading::{
1425 OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
1426 };
1427
1428 struct ProcessHandle(HANDLE);
1432
1433 impl Drop for ProcessHandle {
1434 fn drop(&mut self) {
1435 unsafe {
1439 CloseHandle(self.0);
1440 }
1441 }
1442 }
1443
1444 pub fn is_alive(pid: u32) -> bool {
1452 let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
1456 if raw.is_null() {
1457 let err = unsafe { GetLastError() };
1460 #[expect(
1461 clippy::match_same_arms,
1462 reason = "named arm documents the cross-session case"
1463 )]
1464 return match err {
1465 ERROR_INVALID_PARAMETER => false,
1466 ERROR_ACCESS_DENIED => true,
1467 _ => true,
1468 };
1469 }
1470 let handle = ProcessHandle(raw);
1471 let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
1474 wait_result != WAIT_OBJECT_0
1475 }
1476}
1477
1478impl Drop for BaseWorktree {
1479 fn drop(&mut self) {
1480 if self.persistent {
1481 return;
1482 }
1483 let _ = std::fs::remove_dir_all(&self.path);
1487 }
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492 use super::*;
1493
1494 #[test]
1500 fn non_reusable_worktree_paths_are_unique_under_concurrency() {
1501 const N: usize = 64;
1502 let barrier = std::sync::Barrier::new(N);
1503 let paths = std::sync::Mutex::new(Vec::with_capacity(N));
1504 std::thread::scope(|s| {
1505 for _ in 0..N {
1506 let barrier = &barrier;
1507 let paths = &paths;
1508 s.spawn(move || {
1509 barrier.wait();
1510 let path = non_reusable_worktree_path().expect("path should build");
1511 paths.lock().unwrap().push(path);
1512 });
1513 }
1514 });
1515 let mut paths = paths.into_inner().unwrap();
1516 assert_eq!(paths.len(), N);
1517 paths.sort();
1518 paths.dedup();
1519 assert_eq!(paths.len(), N, "non-reusable worktree paths collided");
1520 }
1521
1522 #[test]
1524 fn non_reusable_worktree_path_pid_is_parseable() {
1525 let path = non_reusable_worktree_path().expect("path should build");
1526 let name = path.file_name().unwrap().to_str().unwrap();
1527 assert!(is_fallow_audit_worktree_path(&path));
1528 assert!(!is_reusable_audit_worktree_path(&path));
1529 assert_eq!(audit_worktree_pid(name), Some(std::process::id()));
1530 }
1531
1532 #[cfg(unix)]
1533 #[test]
1534 fn cache_sidecar_open_does_not_follow_symlinks() {
1535 let temp = tempfile::TempDir::new().expect("temp dir should be created");
1536 let victim = temp.path().join("victim");
1537 let sidecar = temp.path().join("cache.lock");
1538 std::fs::write(&victim, "unchanged\n").expect("victim should be written");
1539 std::os::unix::fs::symlink(&victim, &sidecar).expect("sidecar symlink should be created");
1540
1541 assert!(open_or_create_owned_sidecar(&sidecar).is_err());
1542 assert_eq!(
1543 std::fs::read_to_string(victim).expect("victim should remain readable"),
1544 "unchanged\n",
1545 );
1546 }
1547}