grok-build-upstream-mirror/crates/codegen/xai-fast-worktree/src/auto_gc.rs
grokkybara[bot] dd04f397b1 Synced from monorepo
Synced from monorepo

Changes:
- Temporarily disable session share link creation in the TUI
- Do not approve plan on empty Enter from the revise prompt
- Expose chat product Skills via ACP available_commands_update
- Return immediately from a blocking wait on an already-completed ACP task
- Split headless pager module for clearer structure
- Stop git worktree prune from removing user registrations on resume
- Use compaction sampler tokenizer for item token counts
- Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE
- Cancel all session subagents when the user stops
- Let the session persistence actor exit when its session ends
- Make fullscreen terminal resize much cheaper on long sessions
- Report honestly from kill_task when an ACP task does not exist
- Hide /usage for external-auth deployments
- Forward the history-load trailer’s computer_reason to the client
- Remove ineffective no-op tool reminder
- Declare slash-command screen-mode support in one place
- Keep settings enum picker on the committed value until Enter
- Reap a PTY’s full process tree
- Stream tool calls from headless mode over ACP
- Bridge gateway task lifecycle to ACP for chat session background tasks
- Don’t warn about truncated history on a suppressed replay
- Fit full-replace summarizer input and recover on context-length errors
- Stop dropping agents over an unrecognized frontmatter color
- Add /undo as a slash alias for /rewind
- Harden sleep/wake token-refresh paths against forced re-login
- Add session/list ACP method
- Give each sampling backend its own conversion module
- Treat an unenrolled child process as a lint error
- Suppress the cancelled marker on send-now wake turns
- Stop tearing down Roslyn on every edit, and read C# diagnostics

Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
2026-07-30 19:07:40 +00:00

2385 lines
80 KiB
Rust

//! Throttled automatic worktree GC (feature `metadata`).
use std::collections::{BTreeMap, BTreeSet};
use std::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};
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<WorktreeKind, Option<i64>> {
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<bool>,
pub max_age_secs: Option<u64>,
pub min_interval_secs: Option<u64>,
pub dry_run: Option<bool>,
pub include_orphan_snapshots: Option<bool>,
pub max_age_by_kind: BTreeMap<WorktreeKind, Option<u64>>,
/// Optional discovery rebuild + grok-scoped stale `.git/worktrees/` scrub (default off).
pub include_rebuild: Option<bool>,
/// Independent rebuild throttle; absent ⇒ 24h.
pub rebuild_min_interval_secs: Option<u64>,
}
/// 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<WorktreeKind, Option<i64>>,
/// 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<u64>) -> Option<i64> {
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<WorktreeKind, Option<u64>>>,
remote: Option<&BTreeMap<WorktreeKind, Option<u64>>>,
) -> BTreeMap<WorktreeKind, Option<i64>> {
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<WorktreeKind, Option<i64>>,
/// 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<GcReport>,
pub overlay: Option<CleanupReport>,
pub btrfs: Option<CleanupReport>,
pub age_expiry_enabled: bool,
pub stamped: bool,
/// Present only when a rebuild pass ran in this invocation.
pub rebuild: Option<RebuildReport>,
/// 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<u64> {
match std::env::var(ENV_AUTO_GC_MAX_AGE) {
Ok(v) => {
let s = v.trim();
if s.is_empty() {
return None;
}
s.parse::<u64>().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<PathBuf>) -> 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<PathBuf>,
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<AutoGcReport> {
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::<i64>() {
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);
// Scrub 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<Option<String>>,
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::<i64>() {
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<RebuildReport>, 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<PathBuf> {
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()
}
/// Scrub stale grok-owned registrations from each known source repo,
/// scoped to worktrees under the grok home to prove ownership (see
/// [`crate::git::remove_stale_worktree_registrations`] for why a blanket
/// `git worktree prune` is unsafe here).
fn prune_stale_git_worktree_registrations(repos: &BTreeSet<PathBuf>) -> u64 {
let Ok(grok_home) = resolve_grok_home() else {
tracing::warn!("auto worktree registration scrub skipped: grok home unresolved");
return 0;
};
let cleaned: u64 = repos
.iter()
.filter(|repo| repo.is_dir())
.map(|repo| crate::git::remove_stale_worktree_registrations_under(repo, &grok_home))
.fold(0u64, u64::saturating_add);
if cleaned > 0 {
tracing::info!(
stale_registrations_cleaned = cleaned,
"auto worktree stale git registrations scrubbed"
);
}
cleaned
}
fn run_orphan_cleaners(
dry_run: bool,
include_orphan_snapshots: bool,
) -> (Option<CleanupReport>, Option<CleanupReport>) {
#[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<AutoGcReport> {
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::path::Path;
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::<i64>().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 prune_keeps_foreign_registrations_outside_grok_home() {
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("foreign-src");
let user_dir = tempfile::TempDir::new().unwrap();
let user_wt = user_dir.path().join("user-wt");
plant_stale_git_worktree(&repo, &user_wt);
let before = count_regs(&repo);
assert!(before >= 1);
let tracked = fx.home.join("foreign-tracked");
std::fs::create_dir_all(&tracked).unwrap();
let mut rec = make_rec("fk-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,
..AutoGcOptions::default()
},
)
.unwrap();
assert_eq!(
report.stale_registrations_cleaned, 0,
"foreign registrations must never be scrubbed"
);
assert_eq!(
count_regs(&repo),
before,
"user registrations outside grok home must survive"
);
}
#[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::<i64>().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());
}
}