//! Throttled automatic worktree GC (feature `metadata`). use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use anyhow::Result; use crate::CleanupReport; use crate::api::gc::{GcOptions, GcReport, age_path_enabled, gc_worktrees}; use crate::db::{ListFilter, WorktreeDb, WorktreeKind, now_epoch_secs, resolve_grok_home}; use crate::discovery::{RebuildReport, rebuild_worktree_db}; use crate::git::checkout::git_command; pub const META_LAST_AUTO_GC_AT: &str = "last_auto_gc_at"; /// Independent throttle stamp for optional DB rebuild (not shared with GC). pub const META_LAST_AUTO_REBUILD_AT: &str = "last_auto_rebuild_at"; /// `0` / `false` / `off` / empty disables auto-GC. pub const ENV_AUTO_GC: &str = "GROK_WORKTREE_AUTO_GC"; /// `1` / `true` / `on` forces age-count without delete. pub const ENV_AUTO_GC_DRY_RUN: &str = "GROK_WORKTREE_AUTO_GC_DRY_RUN"; /// Default max age in seconds (overrides TOML/remote when set and parseable). pub const ENV_AUTO_GC_MAX_AGE: &str = "GROK_WORKTREE_AUTO_GC_MAX_AGE"; /// `1` / `true` / `on` enables optional discovery rebuild + stale git prune. pub const ENV_AUTO_GC_REBUILD: &str = "GROK_WORKTREE_AUTO_GC_REBUILD"; pub const DEFAULT_MAX_AGE_SECS: i64 = 7 * 86400; pub const DEFAULT_MIN_INTERVAL_SECS: i64 = 6 * 3600; /// Rebuild is costlier than GC; default 24h until cost is measured in dogfood. pub const DEFAULT_REBUILD_MIN_INTERVAL_SECS: i64 = 24 * 3600; pub const MAX_AGE_SECS_MIN: i64 = 3600; pub const MAX_AGE_SECS_MAX: i64 = 90 * 86400; pub const MIN_INTERVAL_SECS_MIN: i64 = 60; pub const MIN_INTERVAL_SECS_MAX: i64 = 7 * 86400; /// Product default: Manual never age-expires unless config overrides. pub fn default_max_age_by_kind() -> BTreeMap> { BTreeMap::from([(WorktreeKind::Manual, None)]) } /// Compile-time CWD-scan platforms (Linux/macOS). Runtime failure fail-closes in `gc_worktrees`. pub fn process_cwd_scan_available() -> bool { cfg!(any(target_os = "linux", target_os = "macos")) } /// Real age-expiry when a CWD-scan platform, or dry-run metrics without deletes. pub fn age_expiry_allowed(scan_platform: bool, dry_run: bool) -> bool { scan_platform || dry_run } /// One local/remote config layer (`max_age_by_kind`: `Some(secs)` or `None`=never). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct WorktreeAutoGcLayer { pub enabled: Option, pub max_age_secs: Option, pub min_interval_secs: Option, pub dry_run: Option, pub include_orphan_snapshots: Option, pub max_age_by_kind: BTreeMap>, /// Optional discovery rebuild + stale `.git/worktrees/` prune (default off). pub include_rebuild: Option, /// Independent rebuild throttle; absent ⇒ 24h. pub rebuild_min_interval_secs: Option, } /// Policy after env / TOML / remote merge. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResolvedWorktreeAutoGc { pub enabled: bool, pub max_age_secs: i64, pub min_interval_secs: i64, pub dry_run: bool, pub include_orphan_snapshots: bool, pub max_age_by_kind: BTreeMap>, /// Off by default until rebuild cost is measured. pub include_rebuild: bool, pub rebuild_min_interval_secs: i64, } impl Default for ResolvedWorktreeAutoGc { fn default() -> Self { Self { enabled: true, max_age_secs: DEFAULT_MAX_AGE_SECS, min_interval_secs: DEFAULT_MIN_INTERVAL_SECS, dry_run: false, include_orphan_snapshots: cfg!(target_os = "linux"), max_age_by_kind: default_max_age_by_kind(), include_rebuild: false, rebuild_min_interval_secs: DEFAULT_REBUILD_MIN_INTERVAL_SECS, } } } impl ResolvedWorktreeAutoGc { /// Defaults plus env kill / dry-run / max-age only. pub fn from_env_only() -> Self { resolve_worktree_auto_gc_from_layers(None, None) } } fn clamp_kind_age(secs: Option) -> Option { secs.map(clamp_max_age_secs) } /// Merge kind maps: product default Manual=never, then remote, then local. fn merge_max_age_by_kind( local: Option<&BTreeMap>>, remote: Option<&BTreeMap>>, ) -> BTreeMap> { let mut map = default_max_age_by_kind(); if let Some(r) = remote { for (&k, &v) in r { map.insert(k, clamp_kind_age(v)); } } if let Some(l) = local { for (&k, &v) in l { map.insert(k, clamp_kind_age(v)); } } map } /// Precedence: env > local > remote > defaults (with numeric clamps). pub fn resolve_worktree_auto_gc_from_layers( local: Option<&WorktreeAutoGcLayer>, remote: Option<&WorktreeAutoGcLayer>, ) -> ResolvedWorktreeAutoGc { let enabled = if env_auto_gc_disabled() { false } else { local .and_then(|s| s.enabled) .or(remote.and_then(|s| s.enabled)) .unwrap_or(true) }; let max_age_secs = env_auto_gc_max_age() .or(local.and_then(|s| s.max_age_secs)) .or(remote.and_then(|s| s.max_age_secs)) .map(clamp_max_age_secs) .unwrap_or(DEFAULT_MAX_AGE_SECS); let min_interval_secs = local .and_then(|s| s.min_interval_secs) .or(remote.and_then(|s| s.min_interval_secs)) .map(clamp_min_interval_secs) .unwrap_or(DEFAULT_MIN_INTERVAL_SECS); let dry_run = if env_auto_gc_dry_run() { true } else { local .and_then(|s| s.dry_run) .or(remote.and_then(|s| s.dry_run)) .unwrap_or(false) }; let include_orphan_snapshots = local .and_then(|s| s.include_orphan_snapshots) .or(remote.and_then(|s| s.include_orphan_snapshots)) .unwrap_or(cfg!(target_os = "linux")); // Env REBUILD=1 forces on; config cannot disable over env. let include_rebuild = if env_auto_gc_rebuild() { true } else { local .and_then(|s| s.include_rebuild) .or(remote.and_then(|s| s.include_rebuild)) .unwrap_or(false) }; let rebuild_min_interval_secs = local .and_then(|s| s.rebuild_min_interval_secs) .or(remote.and_then(|s| s.rebuild_min_interval_secs)) .map(clamp_min_interval_secs) .unwrap_or(DEFAULT_REBUILD_MIN_INTERVAL_SECS); ResolvedWorktreeAutoGc { enabled, max_age_secs, min_interval_secs, dry_run, include_orphan_snapshots, max_age_by_kind: merge_max_age_by_kind( local.map(|s| &s.max_age_by_kind), remote.map(|s| &s.max_age_by_kind), ), include_rebuild, rebuild_min_interval_secs, } } /// Runtime options for [`maybe_auto_gc`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AutoGcOptions { /// Env kill still wins inside [`maybe_auto_gc`]. pub enabled: bool, pub max_age_secs: i64, pub min_interval_secs: i64, pub include_orphan_snapshots: bool, /// Env dry-run is re-applied inside [`maybe_auto_gc`]. pub dry_run: bool, pub max_age_by_kind: BTreeMap>, /// When true, optionally rebuild DB from disk + prune stale git registrations. pub include_rebuild: bool, pub rebuild_min_interval_secs: i64, } impl Default for AutoGcOptions { fn default() -> Self { Self::from_resolved(ResolvedWorktreeAutoGc::default()) } } impl AutoGcOptions { pub fn from_resolved(policy: ResolvedWorktreeAutoGc) -> Self { Self { enabled: policy.enabled, max_age_secs: policy.max_age_secs, min_interval_secs: policy.min_interval_secs, include_orphan_snapshots: policy.include_orphan_snapshots, dry_run: policy.dry_run, max_age_by_kind: policy.max_age_by_kind, include_rebuild: policy.include_rebuild, rebuild_min_interval_secs: policy.rebuild_min_interval_secs, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum AutoGcOutcome { Disabled, Throttled, Ran, } #[derive(Debug)] pub struct AutoGcReport { pub outcome: AutoGcOutcome, pub gc: Option, pub overlay: Option, pub btrfs: Option, pub age_expiry_enabled: bool, pub stamped: bool, /// Present only when a rebuild pass ran in this invocation. pub rebuild: Option, /// True when `last_auto_rebuild_at` was written this pass. pub rebuild_stamped: bool, /// Entries removed from known source repos' `.git/worktrees/` via prune. pub stale_registrations_cleaned: u64, } impl AutoGcReport { fn empty(outcome: AutoGcOutcome) -> Self { Self { outcome, gc: None, overlay: None, btrfs: None, age_expiry_enabled: false, stamped: false, rebuild: None, rebuild_stamped: false, stale_registrations_cleaned: 0, } } fn disabled() -> Self { Self::empty(AutoGcOutcome::Disabled) } fn throttled() -> Self { Self::empty(AutoGcOutcome::Throttled) } } fn env_var_truthy(name: &str) -> bool { match std::env::var(name) { Ok(v) => matches!( v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" | "enabled" ), Err(_) => false, } } fn env_var_disabled(name: &str) -> bool { match std::env::var(name) { Ok(v) => matches!( v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off" | "disabled" | "" ), Err(_) => false, } } pub fn env_auto_gc_disabled() -> bool { env_var_disabled(ENV_AUTO_GC) } pub fn env_auto_gc_dry_run() -> bool { env_var_truthy(ENV_AUTO_GC_DRY_RUN) } pub fn env_auto_gc_rebuild() -> bool { env_var_truthy(ENV_AUTO_GC_REBUILD) } /// Parse `GROK_WORKTREE_AUTO_GC_MAX_AGE` as seconds; invalid/absent → None. pub fn env_auto_gc_max_age() -> Option { match std::env::var(ENV_AUTO_GC_MAX_AGE) { Ok(v) => { let s = v.trim(); if s.is_empty() { return None; } s.parse::().ok() } Err(_) => None, } } pub fn clamp_max_age_secs(v: u64) -> i64 { // Cap in u64 so values > i64::MAX do not wrap negative. let capped = v.min(MAX_AGE_SECS_MAX as u64) as i64; capped.max(MAX_AGE_SECS_MIN) } pub fn clamp_min_interval_secs(v: u64) -> i64 { let capped = v.min(MIN_INTERVAL_SECS_MAX as u64) as i64; capped.max(MIN_INTERVAL_SECS_MIN) } /// Auto-path `GcOptions` (`force=false`, kind policy map, protect paths canon once). pub fn build_auto_gc_options(auto_opts: &AutoGcOptions, protect_paths: Vec) -> GcOptions { build_auto_gc_options_with_dry_run(auto_opts, protect_paths, auto_opts.dry_run) } fn build_auto_gc_options_with_dry_run( auto_opts: &AutoGcOptions, protect_paths: Vec, dry_run: bool, ) -> GcOptions { let age_allowed = age_expiry_allowed(process_cwd_scan_available(), dry_run); let protect_paths = protect_paths .into_iter() .map(|p| dunce::canonicalize(&p).unwrap_or(p)) .collect(); // Clone kind map only when the age path is live; platform-off drops it. let max_age_by_kind = if age_allowed { auto_opts.max_age_by_kind.clone() } else { BTreeMap::new() }; GcOptions { max_age_secs: age_allowed.then_some(auto_opts.max_age_secs), force: false, dry_run, protect_paths, skip_kinds: vec![], max_age_by_kind, } } /// Future stamps (clock skew) are treated as due so throttle cannot black out forever. pub(crate) fn is_throttled(now: i64, last: i64, min_interval_secs: i64) -> bool { if last > now { return false; } now.saturating_sub(last) < min_interval_secs.max(0) } /// Throttled auto-GC. `Ok` always carries a report; `Err` means infrastructure /// failure before/during GC (not stamped). Env kill/dry-run/rebuild override /// raw options. GC throttle short-circuits the whole pass (including rebuild). pub fn maybe_auto_gc(db: &WorktreeDb, auto_opts: &AutoGcOptions) -> Result { if env_auto_gc_disabled() || !auto_opts.enabled { tracing::debug!("auto worktree gc disabled"); return Ok(AutoGcReport::disabled()); } let dry_run = auto_opts.dry_run || env_auto_gc_dry_run(); let include_rebuild = auto_opts.include_rebuild || env_auto_gc_rebuild(); let now = now_epoch_secs(); // GC meta: fail closed on read Err; unparseable fails open. Throttle skips rebuild too. if let Some(ts) = db.get_meta(META_LAST_AUTO_GC_AT)? { match ts.parse::() { Ok(last) if is_throttled(now, last, auto_opts.min_interval_secs) => { tracing::debug!( last_auto_gc_at = last, min_interval_secs = auto_opts.min_interval_secs, "auto worktree gc throttled" ); return Ok(AutoGcReport::throttled()); } Ok(_) => {} Err(_) => { tracing::warn!( value = %ts, "auto worktree gc ignoring unparseable last_auto_gc_at; running reclaim" ); } } } // Rebuild before the prune-repo snapshot so newly registered worktrees' // source repos are included. Snapshot still happens before dead-GC so // sole-dead source repos remain in the set after unregister. // // Rebuild meta is **not** stamped here: if GC fails after a successful // rebuild, we must leave rebuild unthrottled so the next pass can pick up // worktrees created between this rebuild and the failed GC. let (rebuild, rebuild_due_to_stamp) = maybe_run_rebuild( db, include_rebuild, dry_run, auto_opts.rebuild_min_interval_secs, now, ); let prune_repos = if include_rebuild && !dry_run { collect_source_repos_for_prune(db) } else { BTreeSet::new() }; let mut protect_paths = Vec::new(); if let Ok(cwd) = std::env::current_dir() { protect_paths.push(cwd); } let gc_opts = build_auto_gc_options_with_dry_run(auto_opts, protect_paths, dry_run); let age_expiry_enabled = age_path_enabled(&gc_opts); debug_assert!(!gc_opts.force); let gc_report = match gc_worktrees(db, &gc_opts) { Ok(r) => r, Err(e) => { tracing::warn!( error = %e, rebuild_ran = rebuild.is_some(), "auto worktree gc failed; rebuild meta left unstamped so next pass can re-discover" ); return Err(e); } }; if gc_report.remove_failed > 0 { tracing::warn!( remove_failed = gc_report.remove_failed, "auto worktree gc had remove failures" ); } let (overlay, btrfs) = run_orphan_cleaners(dry_run, auto_opts.include_orphan_snapshots); // Prune each full pass when opted in (cheap vs discovery; not rebuild-throttled). let stale_registrations_cleaned = if include_rebuild && !dry_run { prune_stale_git_worktree_registrations(&prune_repos) } else { 0 }; let stamp_now = now_epoch_secs(); // Stamp rebuild only after GC succeeds (see maybe_run_rebuild). let rebuild_stamped = if rebuild_due_to_stamp { match db.set_meta(META_LAST_AUTO_REBUILD_AT, &stamp_now.to_string()) { Ok(()) => true, Err(e) => { tracing::warn!(error = %e, "auto worktree rebuild failed to stamp meta"); false } } } else { false }; let stamped = match db.set_meta(META_LAST_AUTO_GC_AT, &stamp_now.to_string()) { Ok(()) => true, Err(e) => { tracing::warn!(error = %e, "auto worktree gc failed to stamp meta"); false } }; let overlay_errors = overlay.as_ref().map(|r| r.errors).unwrap_or(0); let btrfs_errors = btrfs.as_ref().map(|r| r.errors).unwrap_or(0); let rebuild_discovered = rebuild.as_ref().map(|r| r.discovered).unwrap_or(0); let rebuild_registered = rebuild.as_ref().map(|r| r.registered).unwrap_or(0); tracing::info!( age_expiry_enabled, dead_removed = gc_report.dead_removed, expired_removed = gc_report.expired_removed, skipped_alive = gc_report.skipped_alive, remove_failed = gc_report.remove_failed, overlay_errors, btrfs_errors, rebuild_discovered, rebuild_registered, stale_registrations_cleaned, dry_run, stamped, rebuild_stamped, "auto worktree gc complete" ); Ok(AutoGcReport { outcome: AutoGcOutcome::Ran, gc: Some(gc_report), overlay, btrfs, age_expiry_enabled, stamped, rebuild, rebuild_stamped, stale_registrations_cleaned, }) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RebuildMetaClass { Due, Throttled, /// Meta read failed — skip rebuild, do not abort GC. SkipFailed, } /// `Err` ⇒ skip rebuild only (GC continues). fn classify_rebuild_meta( meta: Result>, now: i64, rebuild_min_interval_secs: i64, ) -> RebuildMetaClass { match meta { Err(e) => { tracing::warn!( error = %e, "auto worktree rebuild skipped: meta read failed; continuing GC" ); RebuildMetaClass::SkipFailed } Ok(None) => RebuildMetaClass::Due, Ok(Some(ts)) => match ts.parse::() { Ok(last) if is_throttled(now, last, rebuild_min_interval_secs) => { tracing::debug!( last_auto_rebuild_at = last, rebuild_min_interval_secs, "auto worktree rebuild throttled" ); RebuildMetaClass::Throttled } Ok(_) => RebuildMetaClass::Due, Err(_) => { tracing::warn!( value = %ts, "auto worktree rebuild ignoring unparseable last_auto_rebuild_at" ); RebuildMetaClass::Due } }, } } /// Optional rebuild; never fails the GC pass. /// /// Returns `(report, due_to_stamp)`. Stamp is applied by the caller **only /// after** GC succeeds — stamping here would throttle rebuild while GC can /// still `Err` and leave `last_auto_gc_at` unstamped. fn maybe_run_rebuild( db: &WorktreeDb, include_rebuild: bool, dry_run: bool, rebuild_min_interval_secs: i64, now: i64, ) -> (Option, bool) { if !include_rebuild || dry_run { return (None, false); } match classify_rebuild_meta( db.get_meta(META_LAST_AUTO_REBUILD_AT), now, rebuild_min_interval_secs, ) { RebuildMetaClass::Due => {} RebuildMetaClass::Throttled | RebuildMetaClass::SkipFailed => return (None, false), } let home = match resolve_grok_home() { Ok(h) => h, Err(e) => { tracing::warn!(error = %e, "auto worktree rebuild skipped: grok home unresolved"); return (None, false); } }; match rebuild_worktree_db(db, &home) { Ok(report) => { tracing::info!( discovered = report.discovered, registered = report.registered, already_tracked = report.already_tracked, "auto worktree db rebuild complete" ); // Defer META_LAST_AUTO_REBUILD_AT until after GC succeeds. (Some(report), true) } Err(e) => { tracing::warn!(error = %e, "auto worktree rebuild failed; continuing GC"); (None, false) } } } /// Distinct non-unknown `source_repo` values (alive + dead) for prune. fn collect_source_repos_for_prune(db: &WorktreeDb) -> BTreeSet { let filter = ListFilter { include_dead: true, ..Default::default() }; let Ok(records) = db.list(&filter) else { tracing::warn!("auto worktree prune skipped: list failed"); return BTreeSet::new(); }; records .into_iter() .filter(|r| r.source_repo.as_os_str() != "unknown") .map(|r| r.source_repo) .collect() } fn prune_stale_git_worktree_registrations(repos: &BTreeSet) -> u64 { let cleaned: u64 = repos .iter() .filter(|repo| repo.is_dir()) .map(|repo| prune_stale_registrations_in_repo(repo)) .fold(0u64, u64::saturating_add); if cleaned > 0 { tracing::info!( stale_registrations_cleaned = cleaned, "auto worktree stale git registrations pruned" ); } cleaned } fn count_git_worktree_registrations(git_worktrees_dir: &Path) -> u64 { let Ok(entries) = std::fs::read_dir(git_worktrees_dir) else { return 0; }; entries .filter_map(Result::ok) .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) .count() as u64 } fn prune_stale_registrations_in_repo(source_repo: &Path) -> u64 { let git_worktrees = source_repo.join(".git").join("worktrees"); let before = count_git_worktree_registrations(&git_worktrees); let output = git_command() .args(["worktree", "prune"]) .current_dir(source_repo) .output(); match output { Ok(o) if o.status.success() => { let after = count_git_worktree_registrations(&git_worktrees); before.saturating_sub(after) } Ok(o) => { tracing::warn!( source_repo = %source_repo.display(), status = %o.status, stderr = %String::from_utf8_lossy(&o.stderr), "git worktree prune failed" ); 0 } Err(e) => { tracing::warn!( source_repo = %source_repo.display(), error = %e, "git worktree prune failed to spawn" ); 0 } } } fn run_orphan_cleaners( dry_run: bool, include_orphan_snapshots: bool, ) -> (Option, Option) { #[cfg(target_os = "linux")] { if dry_run || !include_orphan_snapshots { return (None, None); } let overlay = crate::cleanup_orphaned_overlay_snapshots(); let btrfs = crate::cleanup_orphaned_btrfs_snapshots(); if overlay.errors > 0 { tracing::warn!( errors = overlay.errors, "auto worktree gc overlay orphan cleanup had errors" ); } if btrfs.errors > 0 { tracing::warn!( errors = btrfs.errors, "auto worktree gc btrfs orphan cleanup had errors" ); } (Some(overlay), Some(btrfs)) } #[cfg(not(target_os = "linux"))] { let _ = (dry_run, include_orphan_snapshots); (None, None) } } /// Env + built-in defaults only (no TOML/remote). pub fn maybe_auto_gc_default() -> Result { let db = WorktreeDb::open_default()?; let opts = AutoGcOptions::from_resolved(ResolvedWorktreeAutoGc::from_env_only()); maybe_auto_gc(&db, &opts) } #[cfg(test)] mod tests { use super::*; use crate::db::{WorktreeRecord, WorktreeStatus}; use std::sync::{Mutex, MutexGuard}; static ENV_LOCK: Mutex<()> = Mutex::new(()); fn env_guard() -> MutexGuard<'static, ()> { ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) } fn clear_auto_gc_env() { unsafe { std::env::remove_var(ENV_AUTO_GC); std::env::remove_var(ENV_AUTO_GC_DRY_RUN); std::env::remove_var(ENV_AUTO_GC_MAX_AGE); std::env::remove_var(ENV_AUTO_GC_REBUILD); } } fn make_rec(id: &str, path: PathBuf, kind: WorktreeKind, created_at: i64) -> WorktreeRecord { WorktreeRecord { id: id.to_string(), path, source_repo: "/repo".into(), repo_name: "repo".to_string(), kind, creation_mode: "linked".to_string(), git_ref: None, head_commit: None, session_id: None, creator_pid: None, created_at, last_accessed_at: None, status: WorktreeStatus::Alive, metadata: None, } } fn opts_enabled_no_orphans(dry_run: bool) -> AutoGcOptions { AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, dry_run, ..AutoGcOptions::default() } } /// Git repo + stale linked-worktree registration (working tree deleted). fn plant_stale_git_worktree(repo: &Path, wt: &Path) { std::fs::create_dir_all(repo).unwrap(); assert!( std::process::Command::new("git") .args(["init"]) .current_dir(repo) .output() .unwrap() .status .success() ); for (k, v) in [("user.email", "t@test"), ("user.name", "t")] { assert!( std::process::Command::new("git") .args(["config", k, v]) .current_dir(repo) .output() .unwrap() .status .success() ); } std::fs::write(repo.join("f.txt"), b"x").unwrap(); assert!( std::process::Command::new("git") .args(["add", "f.txt"]) .current_dir(repo) .output() .unwrap() .status .success() ); assert!( std::process::Command::new("git") .args(["commit", "-m", "i"]) .current_dir(repo) .output() .unwrap() .status .success() ); let add_wt = std::process::Command::new("git") .args(["worktree", "add", "--detach", wt.to_str().unwrap(), "HEAD"]) .current_dir(repo) .output() .unwrap(); assert!( add_wt.status.success(), "worktree add failed: {}", String::from_utf8_lossy(&add_wt.stderr) ); std::fs::remove_dir_all(wt).unwrap(); } fn count_regs(repo: &Path) -> usize { std::fs::read_dir(repo.join(".git/worktrees")) .map(|rd| { rd.filter_map(Result::ok) .filter(|e| e.path().is_dir()) .count() }) .unwrap_or(0) } #[test] fn build_auto_gc_options_never_sets_force() { let opts = AutoGcOptions { max_age_secs: 1, min_interval_secs: 1, include_orphan_snapshots: false, ..AutoGcOptions::default() }; let gc = build_auto_gc_options(&opts, Vec::new()); assert!(!gc.force, "auto path must never set force=true"); assert!(gc.skip_kinds.is_empty()); assert_eq!(gc.max_age_by_kind.get(&WorktreeKind::Manual), Some(&None)); } #[test] fn age_expiry_allowed_table() { assert!(!age_expiry_allowed(false, false)); assert!( age_expiry_allowed(false, true), "dry_run enables age metrics" ); assert!( age_expiry_allowed(true, false), "scan platform enables real age" ); assert!(age_expiry_allowed(true, true)); } #[test] fn process_cwd_scan_available_implies_usable_scan() { if !process_cwd_scan_available() { assert!( !age_expiry_allowed(false, false), "no scan platform ⇒ non-dry-run age off" ); return; } // Serialize with chdir tests (process-global cwd / scan validation). let _cwd_lock = crate::api::cwd_test_guard(); match crate::api::gc::live_process_cwds() { crate::api::gc::LiveCwdScan::Ok(cwds) => { let cwd = std::env::current_dir().unwrap(); assert!( cwds.iter().any(|c| { c == &cwd || dunce::canonicalize(c) .ok() .and_then(|cc| dunce::canonicalize(&cwd).ok().map(|w| cc == w)) .unwrap_or(false) }), "available scan must observe own CWD" ); } other => panic!("process_cwd_scan_available but scan unusable: {other:?}"), } } #[test] fn build_auto_gc_options_age_expiry_requires_scan_or_dry_run() { let _g = env_guard(); clear_auto_gc_env(); let opts = AutoGcOptions { max_age_secs: 999, min_interval_secs: 1, include_orphan_snapshots: true, dry_run: false, ..AutoGcOptions::default() }; let gc = build_auto_gc_options(&opts, Vec::new()); assert_eq!( gc.max_age_secs.is_some(), age_expiry_allowed(process_cwd_scan_available(), false) ); if process_cwd_scan_available() { assert_eq!(gc.max_age_secs, Some(999)); assert_eq!(gc.max_age_by_kind.get(&WorktreeKind::Manual), Some(&None)); } else { assert_eq!(gc.max_age_secs, None); assert!(gc.max_age_by_kind.is_empty()); } } #[test] fn build_auto_gc_options_dry_run_may_set_max_age_without_cwd_scan() { let _g = env_guard(); clear_auto_gc_env(); let opts = AutoGcOptions { max_age_secs: 123, min_interval_secs: 1, include_orphan_snapshots: false, dry_run: true, ..AutoGcOptions::default() }; let gc = build_auto_gc_options(&opts, Vec::new()); assert_eq!(gc.max_age_secs, Some(123)); assert!(gc.dry_run); assert!(!gc.force); assert_eq!(gc.max_age_by_kind.get(&WorktreeKind::Manual), Some(&None)); assert!(age_expiry_allowed(process_cwd_scan_available(), true)); } #[test] fn maybe_auto_gc_age_expiry_tracks_scan_for_real_and_dry_run() { // Complementary: dry_run always enables age metrics; real run only when scan works. let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let dry = maybe_auto_gc(&db, &opts_enabled_no_orphans(true)).unwrap(); assert_eq!(dry.outcome, AutoGcOutcome::Ran); assert!( dry.age_expiry_enabled, "dry_run must enable age metrics even without CWD scan" ); db.set_meta(META_LAST_AUTO_GC_AT, "0").unwrap(); // reset throttle let real = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); assert_eq!(real.outcome, AutoGcOutcome::Ran); assert_eq!( real.age_expiry_enabled, age_expiry_allowed(process_cwd_scan_available(), false), "non-dry-run age_expiry must match age_expiry_allowed(scan, false)" ); } #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn maybe_auto_gc_real_expires_unguarded_protects_live_creator() { // Age path needs a successful CWD scan — serialize with chdir tests. let _g = env_guard(); let _cwd_lock = crate::api::cwd_test_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let doomed = tmp.path().join("doomed-session"); std::fs::create_dir(&doomed).unwrap(); db.register(&make_rec( "doomed", doomed.clone(), WorktreeKind::Session, 1, )) .unwrap(); let kept = tmp.path().join("kept-session"); std::fs::create_dir(&kept).unwrap(); let mut live = make_rec("kept", kept.clone(), WorktreeKind::Session, 1); live.creator_pid = Some(std::process::id()); db.register(&live).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { max_age_secs: 0, min_interval_secs: 0, include_orphan_snapshots: false, dry_run: false, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!(report.age_expiry_enabled); assert!( !doomed.exists(), "unguarded expired session must be age-deleted on scan platforms" ); assert!( kept.exists(), "live creator_pid must protect the other tree" ); let gc = report.gc.as_ref().unwrap(); assert!(gc.expired_removed >= 1); assert!(gc.skipped_alive >= 1); } #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn maybe_auto_gc_default_never_expires_manual() { let _g = env_guard(); let _cwd_lock = crate::api::cwd_test_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let session = tmp.path().join("sess"); let manual = tmp.path().join("man"); std::fs::create_dir(&session).unwrap(); std::fs::create_dir(&manual).unwrap(); db.register(&make_rec("s", session.clone(), WorktreeKind::Session, 1)) .unwrap(); db.register(&make_rec("m", manual.clone(), WorktreeKind::Manual, 1)) .unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { max_age_secs: 0, min_interval_secs: 0, include_orphan_snapshots: false, dry_run: false, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!(!session.exists(), "session expires under default policy"); assert!(manual.exists(), "manual never age-expires by default"); } #[test] fn env_dry_run_forces_dry_run_on_raw_opts() { // Raw AutoGcOptions dry_run=false must still dry-run when env is set. let _g = env_guard(); clear_auto_gc_env(); unsafe { std::env::set_var(ENV_AUTO_GC_DRY_RUN, "1") }; let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let dir = tmp.path().join("would-expire"); std::fs::create_dir(&dir).unwrap(); db.register(&make_rec("exp", dir.clone(), WorktreeKind::Session, 1)) .unwrap(); let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!( dir.exists(), "env dry-run must not delete even when opts.dry_run=false" ); assert!(report.age_expiry_enabled, "dry-run enables age metrics"); clear_auto_gc_env(); } #[test] fn dry_run_skips_orphan_cleaners() { let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: true, dry_run: true, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!( report.overlay.is_none() && report.btrfs.is_none(), "dry_run must not invoke orphan cleaners" ); } #[cfg(target_os = "linux")] #[test] fn non_dry_run_runs_orphan_cleaners_on_linux() { // Complementary to dry_run_skips_orphan_cleaners (Linux-only symbols). let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: true, dry_run: false, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!( report.overlay.is_some() && report.btrfs.is_some(), "non-dry-run + include_orphan_snapshots must invoke cleaners on Linux" ); } #[cfg(not(target_os = "linux"))] #[test] fn non_linux_orphan_cleaners_always_absent() { // Orphan cleaners are compile-gated; non-Linux always returns None. let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: true, dry_run: false, ..AutoGcOptions::default() }, ) .unwrap(); assert!(report.overlay.is_none() && report.btrfs.is_none()); } #[test] fn force_never_applied_by_auto_path_on_live_pid() { let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let dir = tmp.path().join("live-wt"); std::fs::create_dir(&dir).unwrap(); let mut rec = make_rec("live", dir.clone(), WorktreeKind::Session, 1); rec.creator_pid = Some(std::process::id()); db.register(&rec).unwrap(); let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(true)).unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!(dir.exists()); let gc = report.gc.unwrap(); assert_eq!(gc.expired_removed, 0); assert_eq!(gc.skipped_alive, 1); } #[test] fn maybe_auto_gc_protects_process_cwd() { // Lock order: ENV_LOCK then CWD_TEST_LOCK. let _g = env_guard(); let _cwd_lock = crate::api::cwd_test_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let dir = tmp.path().join("cwd-wt"); std::fs::create_dir(&dir).unwrap(); db.register(&make_rec("cwd", dir.clone(), WorktreeKind::Session, 1)) .unwrap(); let _cwd = crate::api::CwdGuard(std::env::current_dir().unwrap()); std::env::set_current_dir(&dir).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { max_age_secs: 0, min_interval_secs: 0, include_orphan_snapshots: false, dry_run: true, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); let gc = report.gc.unwrap(); assert_eq!( gc.expired_removed, 0, "process cwd inside wt must not count as would-expire" ); assert!( gc.skipped_alive >= 1, "protect_paths must skip the cwd worktree" ); assert!(dir.exists()); } #[test] fn throttle_gc_ok_stamps_and_within_interval_throttles() { let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let opts = AutoGcOptions { min_interval_secs: 3600, include_orphan_snapshots: false, dry_run: false, ..AutoGcOptions::default() }; assert!(db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_none()); let first = maybe_auto_gc(&db, &opts).unwrap(); assert_eq!(first.outcome, AutoGcOutcome::Ran); assert!(first.stamped); assert_eq!( first.age_expiry_enabled, process_cwd_scan_available(), "non-dry-run age_expiry tracks process_cwd_scan_available" ); let stamp = db.get_meta(META_LAST_AUTO_GC_AT).unwrap(); assert!(stamp.is_some(), "GC Ok must stamp last_auto_gc_at"); let second = maybe_auto_gc(&db, &opts).unwrap(); assert_eq!(second.outcome, AutoGcOutcome::Throttled); assert_eq!( db.get_meta(META_LAST_AUTO_GC_AT).unwrap(), stamp, "throttled pass must not rewrite stamp" ); } #[test] fn future_stamp_is_treated_as_due() { let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); // Far-future stamp must not black out auto-GC forever. db.set_meta( META_LAST_AUTO_GC_AT, &(now_epoch_secs() + 86_400).to_string(), ) .unwrap(); let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); } #[test] fn is_throttled_logic() { assert!(!is_throttled(1000, 2000, 3600), "future stamp is due"); assert!(is_throttled(1000, 900, 3600), "within interval"); assert!(!is_throttled(5000, 1000, 3600), "past interval"); assert!( !is_throttled(1000, 1000, 0), "zero interval never throttles" ); } #[test] fn throttle_gc_err_does_not_stamp() { let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); db.execute_batch_for_test("DROP TABLE worktrees;").unwrap(); let err = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)); assert!(err.is_err(), "GC Err must surface as Err"); assert!( db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_none(), "GC Err must not stamp last_auto_gc_at" ); } #[test] fn throttle_stamps_even_when_remove_failed() { // remove_failed > 0 still stamps (partial progress). let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let path = tmp.path().join("doomed"); std::fs::write(&path, b"not a dir").unwrap(); db.register(&make_rec("doomed", path, WorktreeKind::Session, 1)) .unwrap(); let opts = AutoGcOptions { max_age_secs: 0, min_interval_secs: 0, include_orphan_snapshots: false, dry_run: false, ..AutoGcOptions::default() }; let report = maybe_auto_gc(&db, &opts).unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!( db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_some(), "Ran must stamp even with remove_failed / dead-only" ); assert_eq!( report.age_expiry_enabled, process_cwd_scan_available(), "non-dry-run age_expiry tracks platform CWD scan" ); if process_cwd_scan_available() { let gc = report.gc.as_ref().unwrap(); assert!( gc.remove_failed >= 1, "age path must record remove_failed on non-dir" ); } } #[test] fn disabled_env_wins_even_if_opts_enabled() { let _g = env_guard(); clear_auto_gc_env(); unsafe { std::env::set_var(ENV_AUTO_GC, "0") }; let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Disabled); assert!(db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_none()); clear_auto_gc_env(); } #[test] fn env_kill_truthy_falsy_table() { let _g = env_guard(); for (val, disabled) in [ ("0", true), ("false", true), ("FALSE", true), ("off", true), ("no", true), ("disabled", true), ("", true), ("1", false), ("true", false), ("on", false), ("yes", false), ("enabled", false), ] { clear_auto_gc_env(); unsafe { std::env::set_var(ENV_AUTO_GC, val) }; assert_eq!( env_auto_gc_disabled(), disabled, "ENV_AUTO_GC={val:?} disabled={disabled}" ); } clear_auto_gc_env(); assert!(!env_auto_gc_disabled(), "unset is not disabled"); } #[test] fn env_dry_run_truthy_table() { let _g = env_guard(); for (val, on) in [ ("1", true), ("true", true), ("yes", true), ("on", true), ("enabled", true), ("0", false), ("false", false), ("", false), ("nope", false), ] { clear_auto_gc_env(); unsafe { std::env::set_var(ENV_AUTO_GC_DRY_RUN, val) }; assert_eq!( env_auto_gc_dry_run(), on, "ENV_AUTO_GC_DRY_RUN={val:?} on={on}" ); } clear_auto_gc_env(); assert!(!env_auto_gc_dry_run()); } #[test] fn disabled_opts_returns_disabled() { let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { enabled: false, min_interval_secs: 0, include_orphan_snapshots: false, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Disabled); assert!(db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_none()); } #[test] fn complementary_enabled_runs_when_env_unset() { let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!(db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_some()); } #[test] fn fail_closed_meta_read_returns_err_without_stamp() { let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); db.execute_batch_for_test("DROP TABLE meta;").unwrap(); let err = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)); assert!(err.is_err(), "meta read failure must fail closed"); } #[test] fn auto_path_dead_reclaim_includes_manual_kind() { // never-expire is age-only; dead Manual still unregisters. let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); db.register(&make_rec( "manual-dead", PathBuf::from("/nonexistent/manual-wt"), WorktreeKind::Manual, 100, )) .unwrap(); let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert_eq!(report.gc.as_ref().unwrap().dead_removed, 1); let all = db .list(&ListFilter { include_dead: true, ..Default::default() }) .unwrap(); assert!(all.is_empty()); } #[test] fn resolve_layers_env_kill_and_local_and_clamps() { let _g = env_guard(); clear_auto_gc_env(); let local_off = WorktreeAutoGcLayer { enabled: Some(false), ..Default::default() }; let remote_on = WorktreeAutoGcLayer { enabled: Some(true), max_age_secs: Some(1), min_interval_secs: Some(1), dry_run: Some(false), ..Default::default() }; let p = resolve_worktree_auto_gc_from_layers(Some(&local_off), Some(&remote_on)); assert!(!p.enabled, "local enabled=false beats remote true"); assert_eq!(p.max_age_secs, MAX_AGE_SECS_MIN, "remote TTL still clamped"); assert_eq!(p.max_age_by_kind.get(&WorktreeKind::Manual), Some(&None)); unsafe { std::env::set_var(ENV_AUTO_GC, "0") }; let p = resolve_worktree_auto_gc_from_layers( Some(&WorktreeAutoGcLayer { enabled: Some(true), ..Default::default() }), None, ); assert!(!p.enabled, "env kill wins"); clear_auto_gc_env(); unsafe { std::env::set_var(ENV_AUTO_GC_DRY_RUN, "1") }; let p = resolve_worktree_auto_gc_from_layers( Some(&WorktreeAutoGcLayer { dry_run: Some(false), ..Default::default() }), Some(&WorktreeAutoGcLayer { dry_run: Some(false), ..Default::default() }), ); assert!(p.dry_run, "env dry-run wins over local/remote false"); clear_auto_gc_env(); } #[test] fn resolve_kind_map_local_wins_over_remote_and_clamps() { let _g = env_guard(); clear_auto_gc_env(); let remote = WorktreeAutoGcLayer { max_age_by_kind: BTreeMap::from([ (WorktreeKind::Subagent, Some(1)), // clamps to MIN (WorktreeKind::Manual, Some(86400)), (WorktreeKind::Pool, Some(172800)), ]), ..Default::default() }; let local = WorktreeAutoGcLayer { max_age_by_kind: BTreeMap::from([ (WorktreeKind::Subagent, Some(7200)), // local omits manual → remote's manual expire stays ]), ..Default::default() }; let p = resolve_worktree_auto_gc_from_layers(Some(&local), Some(&remote)); assert_eq!( p.max_age_by_kind.get(&WorktreeKind::Subagent), Some(&Some(7200)), "local kind TTL wins" ); assert_eq!( p.max_age_by_kind.get(&WorktreeKind::Manual), Some(&Some(86400)), "remote can make manual expire when local omits" ); assert_eq!( p.max_age_by_kind.get(&WorktreeKind::Pool), Some(&Some(172800)) ); // Local can restore manual never. let local_never = WorktreeAutoGcLayer { max_age_by_kind: BTreeMap::from([(WorktreeKind::Manual, None)]), ..Default::default() }; let p = resolve_worktree_auto_gc_from_layers(Some(&local_never), Some(&remote)); assert_eq!(p.max_age_by_kind.get(&WorktreeKind::Manual), Some(&None)); } #[test] fn resolve_env_max_age_wins_over_local_and_remote() { let _g = env_guard(); clear_auto_gc_env(); unsafe { std::env::set_var(ENV_AUTO_GC_MAX_AGE, "7200") }; let local = WorktreeAutoGcLayer { max_age_secs: Some(86400), ..Default::default() }; let remote = WorktreeAutoGcLayer { max_age_secs: Some(3600), ..Default::default() }; let p = resolve_worktree_auto_gc_from_layers(Some(&local), Some(&remote)); assert_eq!(p.max_age_secs, 7200, "env MAX_AGE wins"); clear_auto_gc_env(); unsafe { std::env::set_var(ENV_AUTO_GC_MAX_AGE, "not-a-number") }; let p = resolve_worktree_auto_gc_from_layers(Some(&local), None); assert_eq!( p.max_age_secs, 86400, "invalid env max age falls through to local" ); clear_auto_gc_env(); } #[test] fn resolve_defaults_include_manual_never() { let _g = env_guard(); clear_auto_gc_env(); let p = ResolvedWorktreeAutoGc::from_env_only(); assert_eq!(p.max_age_by_kind, default_max_age_by_kind()); assert_eq!(p.max_age_secs, DEFAULT_MAX_AGE_SECS); assert!( !p.include_rebuild, "rebuild off by default until cost measured" ); assert_eq!( p.rebuild_min_interval_secs, DEFAULT_REBUILD_MIN_INTERVAL_SECS ); } #[test] fn resolve_env_rebuild_enables_include_rebuild() { let _g = env_guard(); clear_auto_gc_env(); unsafe { std::env::set_var(ENV_AUTO_GC_REBUILD, "1") }; let p = ResolvedWorktreeAutoGc::from_env_only(); assert!( p.include_rebuild, "env REBUILD=1 must enable include_rebuild" ); clear_auto_gc_env(); let local_off = WorktreeAutoGcLayer { include_rebuild: Some(false), ..Default::default() }; unsafe { std::env::set_var(ENV_AUTO_GC_REBUILD, "1") }; let p = resolve_worktree_auto_gc_from_layers(Some(&local_off), None); assert!(p.include_rebuild, "env REBUILD=1 wins over local false"); clear_auto_gc_env(); } #[test] fn resolve_local_include_rebuild_and_interval() { let _g = env_guard(); clear_auto_gc_env(); let local = WorktreeAutoGcLayer { include_rebuild: Some(true), rebuild_min_interval_secs: Some(120), ..Default::default() }; let p = resolve_worktree_auto_gc_from_layers(Some(&local), None); assert!(p.include_rebuild); assert_eq!(p.rebuild_min_interval_secs, 120); } #[test] fn from_env_only_honors_dry_run_env() { let _g = env_guard(); clear_auto_gc_env(); unsafe { std::env::set_var(ENV_AUTO_GC_DRY_RUN, "1") }; let p = ResolvedWorktreeAutoGc::from_env_only(); assert!(p.dry_run); assert!(p.enabled); clear_auto_gc_env(); } #[test] fn clamps_numeric_bounds() { assert_eq!(clamp_max_age_secs(1), MAX_AGE_SECS_MIN); assert_eq!(clamp_max_age_secs(u64::MAX), MAX_AGE_SECS_MAX); assert_eq!(clamp_max_age_secs(604800), 604800); assert_eq!(clamp_min_interval_secs(1), MIN_INTERVAL_SECS_MIN); assert_eq!(clamp_min_interval_secs(u64::MAX), MIN_INTERVAL_SECS_MAX); } #[test] fn get_set_meta_round_trip() { let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); assert_eq!(db.get_meta("k").unwrap(), None); db.set_meta("k", "v").unwrap(); assert_eq!(db.get_meta("k").unwrap().as_deref(), Some("v")); db.set_meta("k", "v2").unwrap(); assert_eq!(db.get_meta("k").unwrap().as_deref(), Some("v2")); } #[test] fn set_meta_err_after_gc_still_returns_ran() { // Stamp write failure must not turn a successful GC into Err for hooks. let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); db.execute_batch_for_test( "CREATE TRIGGER block_meta_write BEFORE INSERT ON meta BEGIN SELECT RAISE(ABORT, 'blocked'); END;", ) .unwrap(); let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); assert_eq!( report.outcome, AutoGcOutcome::Ran, "set_meta Err after GC Ok must still return Ok(Ran)" ); assert!(!report.stamped, "failed set_meta must report stamped=false"); } #[test] fn unparseable_stamp_fails_open_and_restamps() { let _g = env_guard(); clear_auto_gc_env(); let tmp = tempfile::TempDir::new().unwrap(); let db = WorktreeDb::open(tmp.path()).unwrap(); db.set_meta(META_LAST_AUTO_GC_AT, "not-a-number").unwrap(); let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!(report.stamped); let stamp = db.get_meta(META_LAST_AUTO_GC_AT).unwrap().unwrap(); assert!( stamp.parse::().is_ok(), "must restamp a parseable epoch after unparseable prior value" ); } #[test] fn include_rebuild_false_skips_rebuild_and_does_not_stamp_rebuild_meta() { let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); let wt = fx.home.join("worktrees/repo/untracked-sess"); std::fs::create_dir_all(wt.join(".git")).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: false, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!(report.rebuild.is_none()); assert!(!report.rebuild_stamped); assert_eq!(report.stale_registrations_cleaned, 0); assert!( db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap().is_none(), "include_rebuild=false must not stamp last_auto_rebuild_at" ); assert!( db.get(&wt.to_string_lossy()).unwrap().is_none(), "untracked dir must not be registered when rebuild disabled" ); } #[test] fn include_rebuild_true_registers_untracked_under_grok_home() { let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); let wt = fx.home.join("worktrees/repo/untracked-sess"); std::fs::create_dir_all(wt.join(".git")).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 0, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); let rebuild = report .rebuild .as_ref() .expect("rebuild must run when enabled and due"); assert_eq!(rebuild.discovered, 1); assert_eq!(rebuild.registered, 1); assert!(report.rebuild_stamped); assert!( db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap().is_some(), "successful rebuild must stamp last_auto_rebuild_at" ); assert!( db.get(&wt.to_string_lossy()).unwrap().is_some(), "untracked dir under grok_home/worktrees must be registered" ); } #[test] fn rebuild_throttled_independently_of_gc() { let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); let opts = AutoGcOptions { min_interval_secs: 0, // GC always due include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 3600, ..AutoGcOptions::default() }; let first = maybe_auto_gc(&db, &opts).unwrap(); assert_eq!(first.outcome, AutoGcOutcome::Ran); assert!(first.rebuild.is_some()); assert!(first.rebuild_stamped); let rebuild_stamp = db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap(); assert!(rebuild_stamp.is_some()); // Second GC pass still runs (min_interval 0) but rebuild is throttled. let second = maybe_auto_gc(&db, &opts).unwrap(); assert_eq!(second.outcome, AutoGcOutcome::Ran); assert!( second.rebuild.is_none(), "rebuild within rebuild_min_interval must skip" ); assert!(!second.rebuild_stamped); assert_eq!( db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap(), rebuild_stamp, "throttled rebuild must not rewrite stamp" ); assert!( second.stamped, "GC stamp still advances when rebuild is throttled" ); } #[test] fn rebuild_failure_does_not_block_dead_record_gc() { // INSERT-aborting trigger makes rebuild register fail; SELECT/DELETE for // dead-path GC still work so reclaim continues after rebuild Err. let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); db.register(&make_rec( "dead-after-rebuild-err", PathBuf::from("/nonexistent/dead-wt-rebuild-err"), WorktreeKind::Session, 100, )) .unwrap(); let untracked = fx.home.join("worktrees/repo/untracked-for-fail"); std::fs::create_dir_all(untracked.join(".git")).unwrap(); db.execute_batch_for_test( "CREATE TRIGGER block_worktree_insert BEFORE INSERT ON worktrees BEGIN SELECT RAISE(ABORT, 'rebuild-blocked'); END;", ) .unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 0, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!( report.rebuild.is_none(), "rebuild Err must not populate rebuild report" ); assert!( !report.rebuild_stamped, "failed rebuild must not stamp last_auto_rebuild_at" ); assert!( db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap().is_none(), "failed rebuild must leave rebuild meta unset" ); assert_eq!( report.gc.as_ref().unwrap().dead_removed, 1, "dead-record GC must still run when rebuild fails" ); assert!(report.stamped, "GC Ok must still stamp last_auto_gc_at"); } #[test] fn dry_run_skips_rebuild_and_prune() { let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); let wt = fx.home.join("worktrees/repo/dry-sess"); std::fs::create_dir_all(wt.join(".git")).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 0, dry_run: true, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!(report.rebuild.is_none()); assert!(!report.rebuild_stamped); assert_eq!(report.stale_registrations_cleaned, 0); assert!(db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap().is_none()); assert!(db.get(&wt.to_string_lossy()).unwrap().is_none()); } #[test] fn prune_removes_stale_git_worktree_registration() { let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); let repo = fx.home.join("src-repo"); let wt = fx.home.join("linked-wt"); plant_stale_git_worktree(&repo, &wt); let before = count_regs(&repo); assert!(before >= 1, "expected stale registration before prune"); let tracked = fx.home.join("still-there"); std::fs::create_dir_all(&tracked).unwrap(); let mut rec = make_rec("tracked", tracked, WorktreeKind::Session, now_epoch_secs()); rec.source_repo = repo.clone(); db.register(&rec).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 0, dry_run: false, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!( report.stale_registrations_cleaned >= 1, "stale registration must be pruned; cleaned={}", report.stale_registrations_cleaned ); assert!( count_regs(&repo) < before, "registration count must drop after prune" ); } #[test] fn prune_noop_when_no_stale_registrations() { let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); let repo = fx.home.join("clean-repo"); std::fs::create_dir_all(&repo).unwrap(); let init = std::process::Command::new("git") .args(["init"]) .current_dir(&repo) .output() .unwrap(); assert!(init.status.success()); let path = fx.home.join("alive-wt"); std::fs::create_dir_all(&path).unwrap(); let mut rec = make_rec("alive", path, WorktreeKind::Session, now_epoch_secs()); rec.source_repo = repo; db.register(&rec).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 0, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert_eq!( report.stale_registrations_cleaned, 0, "clean repo must report zero stale prunes" ); } #[test] fn dry_run_skips_prune_even_with_stale_registration() { let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); let repo = fx.home.join("dry-src"); let wt = fx.home.join("dry-stale-wt"); plant_stale_git_worktree(&repo, &wt); let before = count_regs(&repo); assert!(before >= 1); let tracked = fx.home.join("dry-tracked"); std::fs::create_dir_all(&tracked).unwrap(); let mut rec = make_rec("dry-t", tracked, WorktreeKind::Session, now_epoch_secs()); rec.source_repo = repo.clone(); db.register(&rec).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 0, dry_run: true, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.stale_registrations_cleaned, 0); assert_eq!(count_regs(&repo), before, "dry_run must not prune"); } #[test] fn include_rebuild_false_skips_prune_even_with_stale_registration() { let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); let repo = fx.home.join("off-src"); let wt = fx.home.join("off-stale-wt"); plant_stale_git_worktree(&repo, &wt); let before = count_regs(&repo); assert!(before >= 1); let tracked = fx.home.join("off-tracked"); std::fs::create_dir_all(&tracked).unwrap(); let mut rec = make_rec("off-t", tracked, WorktreeKind::Session, now_epoch_secs()); rec.source_repo = repo.clone(); db.register(&rec).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: false, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.stale_registrations_cleaned, 0); assert_eq!( count_regs(&repo), before, "include_rebuild=false must not prune" ); } #[test] fn prune_uses_dead_row_source_repo_snapshot() { // Sole tracked row is dead (path gone); after GC unregisters it, prune must // still hit the snapshotted source_repo. let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); let repo = fx.home.join("dead-src"); let wt = fx.home.join("dead-stale-wt"); plant_stale_git_worktree(&repo, &wt); let before = count_regs(&repo); assert!(before >= 1); let mut rec = make_rec( "sole-dead", PathBuf::from("/nonexistent/sole-dead-wt"), WorktreeKind::Session, 100, ); rec.source_repo = repo.clone(); db.register(&rec).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 0, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.gc.as_ref().unwrap().dead_removed, 1); assert!( report.stale_registrations_cleaned >= 1, "dead sole-row source_repo must still be pruned; cleaned={}", report.stale_registrations_cleaned ); assert!(count_regs(&repo) < before); } #[test] fn rebuild_unparseable_stamp_fails_open() { let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); db.set_meta(META_LAST_AUTO_REBUILD_AT, "not-a-number") .unwrap(); let wt = fx.home.join("worktrees/repo/reparse-sess"); std::fs::create_dir_all(wt.join(".git")).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 3600, ..AutoGcOptions::default() }, ) .unwrap(); assert!( report.rebuild.is_some(), "unparseable rebuild stamp must not throttle" ); assert!(report.rebuild_stamped); let stamp = db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap().unwrap(); assert!(stamp.parse::().is_ok()); } #[test] fn rebuild_set_meta_failure_still_continues_gc() { let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); db.register(&make_rec( "dead-stamp", PathBuf::from("/nonexistent/dead-stamp-wt"), WorktreeKind::Session, 100, )) .unwrap(); // Block only INSERT (UPSERT is INSERT OR REPLACE → INSERT path). db.execute_batch_for_test( "CREATE TRIGGER block_meta_insert BEFORE INSERT ON meta BEGIN SELECT RAISE(ABORT, 'meta-blocked'); END;", ) .unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 0, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); // Rebuild may succeed registering before stamp; both stamps use set_meta INSERT. if report.rebuild.is_some() { assert!(!report.rebuild_stamped); } assert_eq!( report.gc.as_ref().unwrap().dead_removed, 1, "GC must continue after rebuild stamp failure" ); assert!(!report.stamped, "GC stamp also uses set_meta INSERT"); } #[test] fn rebuild_not_stamped_when_gc_fails_after_rebuild() { // Rebuild succeeds (registers untracked), then GC fails on sweep UPDATE. // Rebuild meta must stay unset so the next pass can re-discover. let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); db.register(&make_rec( "alive-missing-path", PathBuf::from("/nonexistent/alive-for-gc-fail"), WorktreeKind::Session, 100, )) .unwrap(); let untracked = fx.home.join("worktrees/repo/rebuild-then-gc-fail"); std::fs::create_dir_all(untracked.join(".git")).unwrap(); db.execute_batch_for_test( "CREATE TRIGGER block_worktree_update BEFORE UPDATE ON worktrees BEGIN SELECT RAISE(ABORT, 'gc-sweep-blocked'); END;", ) .unwrap(); let err = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 0, ..AutoGcOptions::default() }, ) .expect_err("GC sweep UPDATE must fail the pass"); let _ = err; assert!( db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap().is_none(), "rebuild must not stamp when GC fails after a successful rebuild" ); assert!( db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_none(), "GC must not stamp when the pass returns Err" ); // Rebuild already registered the untracked tree before GC failed. assert!( db.get(untracked.to_str().unwrap()).unwrap().is_some(), "rebuild registration from the failed pass is retained" ); } #[test] fn classify_rebuild_meta_err_skips_not_aborts() { let err = Err(anyhow::anyhow!("meta unavailable")); assert_eq!( classify_rebuild_meta(err, 1000, 3600), RebuildMetaClass::SkipFailed ); assert_eq!( classify_rebuild_meta(Ok(None), 1000, 3600), RebuildMetaClass::Due ); assert_eq!( classify_rebuild_meta(Ok(Some("900".into())), 1000, 3600), RebuildMetaClass::Throttled ); assert_eq!( classify_rebuild_meta(Ok(Some("not-a-number".into())), 1000, 3600), RebuildMetaClass::Due ); assert_eq!( classify_rebuild_meta(Ok(Some("100".into())), 10000, 3600), RebuildMetaClass::Due ); } #[test] fn env_rebuild_reapplied_inside_maybe_auto_gc() { let _g = env_guard(); clear_auto_gc_env(); unsafe { std::env::set_var(ENV_AUTO_GC_REBUILD, "1") }; let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); let wt = fx.home.join("worktrees/repo/env-rebuild-sess"); std::fs::create_dir_all(wt.join(".git")).unwrap(); // opts.include_rebuild false — env must still enable. let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: false, rebuild_min_interval_secs: 0, ..AutoGcOptions::default() }, ) .unwrap(); assert!( report.rebuild.is_some(), "env REBUILD must re-apply inside maybe_auto_gc" ); clear_auto_gc_env(); } #[test] fn gc_throttled_short_circuits_rebuild() { let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); // GC recently stamped; rebuild never stamped and would be due. db.set_meta(META_LAST_AUTO_GC_AT, &now_epoch_secs().to_string()) .unwrap(); let wt = fx.home.join("worktrees/repo/throttle-rebuild"); std::fs::create_dir_all(wt.join(".git")).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 3600, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 0, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Throttled); assert!(report.rebuild.is_none()); assert!( db.get(&wt.to_string_lossy()).unwrap().is_none(), "GC throttle must skip rebuild" ); } #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn rebuild_same_pass_does_not_age_expire_new_registration() { let _g = env_guard(); let _cwd_lock = crate::api::cwd_test_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); let wt = fx.home.join("worktrees/repo/fresh-rebuild"); std::fs::create_dir_all(wt.join(".git")).unwrap(); // Old directory mtime would look expired under max_age=0 without touch. let report = maybe_auto_gc( &db, &AutoGcOptions { max_age_secs: 0, min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 0, dry_run: false, ..AutoGcOptions::default() }, ) .unwrap(); assert!(report.age_expiry_enabled); assert!(report.rebuild.as_ref().is_some_and(|r| r.registered == 1)); assert!( wt.exists(), "just-registered rebuild path must not age-delete same pass" ); assert!(db.get(&wt.to_string_lossy()).unwrap().is_some()); } #[cfg(unix)] #[test] fn rebuild_refuses_symlink_escape_then_age_safe() { let _g = env_guard(); clear_auto_gc_env(); let fx = crate::db::GrokHomeFixture::new(); let db = WorktreeDb::open(&fx.home).unwrap(); let outside = fx.home.join("outside-escape"); std::fs::create_dir_all(outside.join(".git")).unwrap(); let parent = fx.home.join("worktrees/repo"); std::fs::create_dir_all(&parent).unwrap(); std::os::unix::fs::symlink(&outside, parent.join("escaped")).unwrap(); let report = maybe_auto_gc( &db, &AutoGcOptions { min_interval_secs: 0, include_orphan_snapshots: false, include_rebuild: true, rebuild_min_interval_secs: 0, ..AutoGcOptions::default() }, ) .unwrap(); assert_eq!(report.outcome, AutoGcOutcome::Ran); assert!( report.rebuild.as_ref().is_some_and(|r| r.registered == 0), "symlink escape must not register" ); assert!( outside.exists(), "outside target must remain (never registered/deleted)" ); assert!(db.list(&ListFilter::default()).unwrap().is_empty()); } }