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
4747 lines
180 KiB
Rust
4747 lines
180 KiB
Rust
//! Public API for fast worktree creation.
|
||
//!
|
||
//! This module provides a higher-level, explicit API (builder + enums) that makes
|
||
//! behavior clear (what to copy, whether to copy ignored files, and how to finalize).
|
||
//!
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
|
||
/// Serializes tests that chdir or assert process-CWD scan results (process-global cwd).
|
||
/// Gated on `metadata` because every caller lives under that feature's test modules
|
||
/// (`gc` / `auto_gc`); without the feature these would be dead under `-D warnings`.
|
||
#[cfg(all(test, feature = "metadata"))]
|
||
pub(crate) static CWD_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||
|
||
#[cfg(all(test, feature = "metadata"))]
|
||
pub(crate) fn cwd_test_guard() -> std::sync::MutexGuard<'static, ()> {
|
||
CWD_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||
}
|
||
|
||
/// Restores process cwd on drop (pair with [`cwd_test_guard`]).
|
||
#[cfg(all(test, feature = "metadata"))]
|
||
pub(crate) struct CwdGuard(pub PathBuf);
|
||
|
||
#[cfg(all(test, feature = "metadata"))]
|
||
impl Drop for CwdGuard {
|
||
fn drop(&mut self) {
|
||
let _ = std::env::set_current_dir(&self.0);
|
||
}
|
||
}
|
||
|
||
use anyhow::Result;
|
||
use tokio_util::sync::CancellationToken;
|
||
|
||
use crate::copy::CopyStats;
|
||
pub use crate::copy::DirtyFilesReport;
|
||
use crate::copy::ParallelCopyConfig;
|
||
|
||
// ============================================================================
|
||
// BtrfsDelegate – delegate privileged btrfs ops to an external service
|
||
// ============================================================================
|
||
|
||
/// Result from a delegated btrfs snapshot creation.
|
||
#[derive(Debug, Clone)]
|
||
pub struct DelegateSnapshotResult {
|
||
/// Path to the actual btrfs snapshot.
|
||
pub snapshot_path: PathBuf,
|
||
/// Path where the worktree is accessible (bind-mounted from `snapshot_path`).
|
||
pub worktree_path: PathBuf,
|
||
/// Whether a bind mount was created from `snapshot_path` to `worktree_path`.
|
||
pub bind_mounted: bool,
|
||
}
|
||
|
||
/// Delegate privileged btrfs operations to an external helper.
|
||
///
|
||
/// When the caller runs inside a sandbox without `CAP_SYS_ADMIN`, it cannot
|
||
/// execute `btrfs subvolume snapshot/delete` directly. This trait lets it
|
||
/// delegate those operations to a privileged process (e.g. over IPC).
|
||
///
|
||
/// Implementations must be `Send + Sync` (shared across threads).
|
||
pub trait BtrfsDelegate: Send + Sync {
|
||
/// Create a btrfs snapshot of `source` accessible at `dest`.
|
||
///
|
||
/// The implementation is expected to:
|
||
/// 1. Detect whether `source` is a btrfs subvolume
|
||
/// 2. Create a snapshot (inside the btrfs filesystem)
|
||
/// 3. Bind mount `dest` from snapshot if source is bind-mounted
|
||
/// 4. Clean up stale git state (lock files, worktree registrations)
|
||
fn create_snapshot(&self, source: &Path, dest: &Path) -> Result<DelegateSnapshotResult>;
|
||
|
||
/// Delete a btrfs snapshot worktree.
|
||
///
|
||
/// If `worktree_path` is a bind mount, the implementation should unmount it,
|
||
/// delete the btrfs snapshot, and clean up the mount point.
|
||
fn delete_snapshot(&self, worktree_path: &Path) -> Result<RemoveReport>;
|
||
|
||
/// Mount an overlayfs at `target` in the *caller's* mount namespace.
|
||
///
|
||
/// A FUSE+overlay worktree needs a new overlay mount, which a rootless
|
||
/// caller can't do (no `CAP_SYS_ADMIN`); the privileged delegate mounts it
|
||
/// inside the caller's namespace (an overlay mount can't be exposed via a
|
||
/// namespace-crossing symlink the way a btrfs snapshot can). Default impl
|
||
/// errors so btrfs-only delegates still compile.
|
||
fn mount_overlay(&self, lower: &Path, upper: &Path, work: &Path, target: &Path) -> Result<()> {
|
||
let _ = (lower, upper, work, target);
|
||
anyhow::bail!("overlay mount delegation not supported by this delegate")
|
||
}
|
||
|
||
/// Unmount an overlay worktree previously mounted via [`Self::mount_overlay`]
|
||
/// (in the caller's mount namespace).
|
||
fn unmount_overlay(&self, target: &Path) -> Result<()> {
|
||
let _ = target;
|
||
anyhow::bail!("overlay unmount delegation not supported by this delegate")
|
||
}
|
||
}
|
||
|
||
/// How to treat the source working tree when creating the destination worktree.
|
||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||
pub enum WorkingTreeMode {
|
||
/// Replicate the working tree exactly as-is (including local modifications and untracked files).
|
||
#[default]
|
||
PreserveWorkingTree,
|
||
/// Produce a clean checked-out working tree for tracked files.
|
||
///
|
||
/// Local modifications and untracked files from the source are not copied.
|
||
CleanTracked,
|
||
/// Produce a clean worktree and also remove any untracked files (equivalent to
|
||
/// `git reset --hard` + `git clean -fd`).
|
||
///
|
||
/// Note: ignored files are not removed by default `git clean`.
|
||
CleanAll,
|
||
}
|
||
|
||
/// Whether (and how) to copy `.gitignore`'d files after the worktree is ready.
|
||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||
pub enum IgnoredFilesMode {
|
||
/// Do not copy ignored files.
|
||
#[default]
|
||
Skip,
|
||
/// Copy ignored files, optionally skipping additional patterns.
|
||
Copy { skip_patterns: Vec<String> },
|
||
/// Copy ONLY ignored files (no worktree creation), optionally skipping additional patterns.
|
||
/// This is for standalone use via `copy_ignored_only()`.
|
||
CopyOnly { skip_patterns: Vec<String> },
|
||
}
|
||
|
||
/// How to handle BTRFS snapshot optimization on Linux.
|
||
///
|
||
/// On Linux systems where the source repo is on a BTRFS subvolume,
|
||
/// we can use BTRFS snapshots for O(1) worktree creation instead of
|
||
/// file-by-file CoW cloning.
|
||
///
|
||
/// The snapshot creates a complete standalone git repository (not a
|
||
/// linked git worktree), which is immediately usable.
|
||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||
pub enum BtrfsMode {
|
||
/// Auto-detect: use BTRFS snapshot if source is on a BTRFS subvolume.
|
||
/// Falls back to file-by-file copy if not on BTRFS or not a subvolume.
|
||
#[default]
|
||
Auto,
|
||
/// Force use of BTRFS snapshot. Returns an error if the source is not
|
||
/// on a BTRFS subvolume.
|
||
Force,
|
||
/// Disable BTRFS snapshot optimization. Always use file-by-file copy.
|
||
Disabled,
|
||
}
|
||
|
||
/// Strategy for creating the worktree.
|
||
///
|
||
/// Consolidates the choice of linked vs standalone, BTRFS snapshots,
|
||
/// and git-native checkout into a single enum.
|
||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||
pub enum CreationMode {
|
||
/// Linked worktree via `git worktree add --no-checkout` followed by
|
||
/// parallel CoW file copy and index finalization. On Linux with BTRFS,
|
||
/// auto-detects and uses instant snapshots when possible.
|
||
///
|
||
/// This is the fastest mode for large repos on APFS/Btrfs.
|
||
#[default]
|
||
Linked,
|
||
|
||
/// Standalone repository copy with its own independent `.git/`
|
||
/// directory (CoW'd from the source). Can be promoted to replace the
|
||
/// source via a simple `rename()`, with no worktree cleanup needed.
|
||
///
|
||
/// On Linux with BTRFS, auto-detects and uses instant snapshots.
|
||
Standalone,
|
||
|
||
/// Plain `git worktree add` with full checkout. Lets git handle the
|
||
/// entire worktree creation including index and working tree
|
||
/// population. Simpler and avoids split-index / index-copy edge
|
||
/// cases, but git does the checkout single-threaded.
|
||
GitCheckout,
|
||
}
|
||
|
||
impl CreationMode {
|
||
pub fn as_db_str(&self) -> &'static str {
|
||
match self {
|
||
Self::Linked => "linked",
|
||
Self::Standalone => "standalone",
|
||
Self::GitCheckout => "git",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A structured report for a copy phase.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct CopyReport {
|
||
pub files_copied: u64,
|
||
pub dirs_created: u64,
|
||
pub symlinks_copied: u64,
|
||
pub files_skipped: u64,
|
||
/// Non-fatal issues encountered during copying.
|
||
pub issues: Vec<String>,
|
||
pub dirty_files: Option<DirtyFilesReport>,
|
||
}
|
||
|
||
impl From<CopyStats> for CopyReport {
|
||
fn from(stats: CopyStats) -> Self {
|
||
Self {
|
||
files_copied: stats.files_copied,
|
||
dirs_created: stats.dirs_created,
|
||
symlinks_copied: stats.symlinks_copied,
|
||
files_skipped: stats.files_skipped,
|
||
issues: stats.issues,
|
||
dirty_files: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Result of creating a worktree via the new API.
|
||
#[derive(Debug)]
|
||
pub struct WorktreeReport {
|
||
pub worktree_path: PathBuf,
|
||
pub commit: String,
|
||
pub unignored_copy: CopyReport,
|
||
pub ignored_copy: Option<CopyReport>,
|
||
}
|
||
|
||
/// High-level builder API for creating fast git worktrees.
|
||
///
|
||
/// All operations are **synchronous/blocking**. Callers should use `spawn_blocking`
|
||
/// when calling from async contexts.
|
||
#[derive(Clone)]
|
||
pub struct WorktreeBuilder {
|
||
source: PathBuf,
|
||
dest: PathBuf,
|
||
git_ref: String,
|
||
parallelism: usize,
|
||
channel_buffer: usize,
|
||
ignored_parallelism: usize,
|
||
working_tree: WorkingTreeMode,
|
||
ignored_files: IgnoredFilesMode,
|
||
creation_mode: CreationMode,
|
||
cancellation_token: CancellationToken,
|
||
btrfs_delegate: Option<Arc<dyn BtrfsDelegate>>,
|
||
#[cfg(feature = "metadata")]
|
||
worktree_kind: Option<crate::db::WorktreeKind>,
|
||
#[cfg(feature = "metadata")]
|
||
session_id: Option<String>,
|
||
#[cfg(feature = "metadata")]
|
||
worktree_id: Option<String>,
|
||
#[cfg(feature = "metadata")]
|
||
metadata: Option<serde_json::Value>,
|
||
}
|
||
|
||
impl std::fmt::Debug for WorktreeBuilder {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.debug_struct("WorktreeBuilder")
|
||
.field("source", &self.source)
|
||
.field("dest", &self.dest)
|
||
.field("git_ref", &self.git_ref)
|
||
.field("parallelism", &self.parallelism)
|
||
.field("creation_mode", &self.creation_mode)
|
||
.field("btrfs_delegate", &self.btrfs_delegate.is_some())
|
||
.finish_non_exhaustive()
|
||
}
|
||
}
|
||
|
||
impl WorktreeBuilder {
|
||
pub fn new(source: impl Into<PathBuf>, dest: impl Into<PathBuf>) -> Self {
|
||
Self {
|
||
source: source.into(),
|
||
dest: dest.into(),
|
||
git_ref: "HEAD".to_string(),
|
||
parallelism: 0,
|
||
channel_buffer: 256,
|
||
ignored_parallelism: 0,
|
||
working_tree: WorkingTreeMode::PreserveWorkingTree,
|
||
ignored_files: IgnoredFilesMode::Skip,
|
||
creation_mode: CreationMode::default(),
|
||
cancellation_token: CancellationToken::new(),
|
||
btrfs_delegate: None,
|
||
#[cfg(feature = "metadata")]
|
||
worktree_kind: None,
|
||
#[cfg(feature = "metadata")]
|
||
session_id: None,
|
||
#[cfg(feature = "metadata")]
|
||
worktree_id: None,
|
||
#[cfg(feature = "metadata")]
|
||
metadata: None,
|
||
}
|
||
}
|
||
|
||
/// Set a cancellation token that can be used to stop a copy operation in progress.
|
||
/// When the token is cancelled, the copy will stop as soon as possible.
|
||
pub fn cancellation_token(mut self, token: CancellationToken) -> Self {
|
||
self.cancellation_token = token;
|
||
self
|
||
}
|
||
|
||
pub fn git_ref(mut self, git_ref: impl Into<String>) -> Self {
|
||
self.git_ref = git_ref.into();
|
||
self
|
||
}
|
||
|
||
pub fn parallelism(mut self, parallelism: usize) -> Self {
|
||
self.parallelism = parallelism;
|
||
self
|
||
}
|
||
|
||
pub fn ignored_parallelism(mut self, parallelism: usize) -> Self {
|
||
self.ignored_parallelism = parallelism;
|
||
self
|
||
}
|
||
|
||
pub fn channel_buffer(mut self, channel_buffer: usize) -> Self {
|
||
self.channel_buffer = channel_buffer;
|
||
self
|
||
}
|
||
|
||
pub fn working_tree_mode(mut self, mode: WorkingTreeMode) -> Self {
|
||
self.working_tree = mode;
|
||
self
|
||
}
|
||
|
||
pub fn ignored_files_mode(mut self, mode: IgnoredFilesMode) -> Self {
|
||
self.ignored_files = mode;
|
||
self
|
||
}
|
||
|
||
/// Set the worktree creation strategy.
|
||
///
|
||
/// - `Linked` (default): `git worktree add --no-checkout` + parallel
|
||
/// CoW file copy + index finalization. Fastest on large repos.
|
||
/// - `Standalone`: Independent `.git/` copy (CoW'd). Can be promoted
|
||
/// to replace the source via `rename()`.
|
||
/// - `GitCheckout`: Plain `git worktree add` with full checkout. Simpler,
|
||
/// avoids split-index issues, but single-threaded checkout.
|
||
pub fn creation_mode(mut self, mode: CreationMode) -> Self {
|
||
self.creation_mode = mode;
|
||
self
|
||
}
|
||
|
||
/// Set the worktree kind for metadata tracking.
|
||
/// When set, `create()` auto-registers the worktree in the metadata DB.
|
||
#[cfg(feature = "metadata")]
|
||
pub fn worktree_kind(mut self, kind: crate::db::WorktreeKind) -> Self {
|
||
self.worktree_kind = Some(kind);
|
||
self
|
||
}
|
||
|
||
/// Set the session ID associated with this worktree.
|
||
#[cfg(feature = "metadata")]
|
||
pub fn session_id(mut self, session_id: impl Into<String>) -> Self {
|
||
self.session_id = Some(session_id.into());
|
||
self
|
||
}
|
||
|
||
/// Override the worktree ID (default: derived from dest path).
|
||
#[cfg(feature = "metadata")]
|
||
pub fn worktree_id(mut self, id: impl Into<String>) -> Self {
|
||
self.worktree_id = Some(id.into());
|
||
self
|
||
}
|
||
|
||
/// Set arbitrary metadata to store alongside the worktree record.
|
||
#[cfg(feature = "metadata")]
|
||
pub fn metadata(mut self, metadata: serde_json::Value) -> Self {
|
||
self.metadata = Some(metadata);
|
||
self
|
||
}
|
||
|
||
/// Shorthand for `.creation_mode(CreationMode::Standalone)`.
|
||
pub fn standalone(mut self, standalone: bool) -> Self {
|
||
if standalone {
|
||
self.creation_mode = CreationMode::Standalone;
|
||
}
|
||
self
|
||
}
|
||
|
||
/// Shorthand for setting the BTRFS snapshot mode (Linux only).
|
||
///
|
||
/// BTRFS snapshots are automatically used by `Linked` and `Standalone`
|
||
/// modes when the source is on a BTRFS subvolume. This method is only
|
||
/// needed to *force* or *disable* that auto-detection.
|
||
pub fn btrfs_mode(self, mode: BtrfsMode) -> Self {
|
||
// BtrfsMode is now handled inside execute.rs based on CreationMode.
|
||
// This method is kept for backward compatibility with the CLI.
|
||
tracing::warn!(
|
||
?mode,
|
||
"WorktreeBuilder::btrfs_mode() is deprecated and has no effect. \
|
||
BtrfsMode is now handled automatically based on CreationMode."
|
||
);
|
||
self
|
||
}
|
||
|
||
/// Set a delegate for privileged btrfs operations.
|
||
///
|
||
/// When the caller lacks `CAP_SYS_ADMIN` (e.g., inside a bwrap sandbox),
|
||
/// btrfs snapshot creation/deletion can be delegated to a privileged
|
||
/// process via this trait. The delegate is tried as a fallback when
|
||
/// direct btrfs operations fail or are unavailable.
|
||
pub fn btrfs_delegate(mut self, delegate: Arc<dyn BtrfsDelegate>) -> Self {
|
||
self.btrfs_delegate = Some(delegate);
|
||
self
|
||
}
|
||
|
||
/// Create the worktree using the configured options.
|
||
///
|
||
/// This is a **blocking** operation. Callers should use `spawn_blocking`
|
||
/// when calling from async contexts.
|
||
pub fn create(self) -> Result<WorktreeReport> {
|
||
// Clone source/git_ref/creation_mode for DB registration before the move
|
||
// into WorktreePlan. These are one-per-create, not a hot path.
|
||
#[cfg(feature = "metadata")]
|
||
let meta_fields = (
|
||
self.worktree_kind,
|
||
self.session_id,
|
||
self.worktree_id,
|
||
self.source.clone(),
|
||
self.creation_mode.as_db_str(),
|
||
self.git_ref.clone(),
|
||
self.metadata,
|
||
);
|
||
|
||
let plan = crate::worktree::WorktreePlan {
|
||
source: self.source,
|
||
dest: self.dest,
|
||
git_ref: self.git_ref,
|
||
parallelism: self.parallelism,
|
||
channel_buffer: self.channel_buffer,
|
||
working_tree: self.working_tree,
|
||
ignored_files: self.ignored_files,
|
||
ignored_parallelism: self.ignored_parallelism,
|
||
creation_mode: self.creation_mode,
|
||
cancellation_token: self.cancellation_token,
|
||
btrfs_delegate: self.btrfs_delegate,
|
||
};
|
||
|
||
let result = crate::worktree::execute_plan(plan).map_err(annotate_disk_full)?;
|
||
|
||
#[cfg(feature = "metadata")]
|
||
{
|
||
let (kind, session_id, wt_id, source, creation_mode, git_ref, metadata) = meta_fields;
|
||
if let Some(kind) = kind {
|
||
register_worktree(
|
||
&result.worktree_path,
|
||
&source,
|
||
kind,
|
||
creation_mode,
|
||
&git_ref,
|
||
&result.commit,
|
||
session_id,
|
||
wt_id,
|
||
metadata,
|
||
);
|
||
}
|
||
}
|
||
|
||
let mut unignored_copy: CopyReport = result.copy_stats.into();
|
||
unignored_copy.dirty_files = result.dirty_files_report;
|
||
|
||
Ok(WorktreeReport {
|
||
worktree_path: result.worktree_path,
|
||
commit: result.commit,
|
||
unignored_copy,
|
||
ignored_copy: result.ignored_stats.map(Into::into),
|
||
})
|
||
}
|
||
|
||
/// Copy ONLY `.gitignore`'d (ignored) files from `source` to `dest`.
|
||
///
|
||
/// This does **not** create or finalize a worktree. It's intended to be run after a
|
||
/// worktree already exists at `dest`, to populate ignored artifacts (node_modules, target, etc.).
|
||
///
|
||
/// This is a **blocking** operation. Callers should use `spawn_blocking`
|
||
/// when calling from async contexts.
|
||
pub fn copy_ignored_only(self) -> Result<CopyReport> {
|
||
let source = &self.source;
|
||
let dest = &self.dest;
|
||
|
||
let num_workers = if self.ignored_parallelism != 0 {
|
||
self.ignored_parallelism
|
||
} else if self.parallelism != 0 {
|
||
self.parallelism
|
||
} else {
|
||
num_cpus::get()
|
||
};
|
||
|
||
let skip_patterns = match self.ignored_files {
|
||
IgnoredFilesMode::Skip => vec![],
|
||
IgnoredFilesMode::Copy { skip_patterns } => skip_patterns,
|
||
IgnoredFilesMode::CopyOnly { skip_patterns } => skip_patterns,
|
||
};
|
||
|
||
tracing::info!(
|
||
source = %source.display(),
|
||
dest = %dest.display(),
|
||
parallelism = num_workers,
|
||
channel_buffer = self.channel_buffer,
|
||
"copying ignored files (ignored-only)"
|
||
);
|
||
|
||
let start = std::time::Instant::now();
|
||
let unignored_paths = crate::copy::collect_unignored_paths(source, num_workers)?;
|
||
|
||
let copy_config = ParallelCopyConfig {
|
||
num_workers,
|
||
channel_buffer: self.channel_buffer,
|
||
skip_files: Some(Arc::new(unignored_paths)),
|
||
respect_gitignore: false,
|
||
skip_patterns,
|
||
};
|
||
|
||
let copy_result =
|
||
crate::copy::copy_parallel(source, dest, copy_config, self.cancellation_token.clone())?;
|
||
|
||
// `copy_parallel` returns Ok with partial stats on cancellation; surface
|
||
// it so an interrupted copy isn't treated as success.
|
||
if self.cancellation_token.is_cancelled() {
|
||
anyhow::bail!("cancelled during ignored-only copy");
|
||
}
|
||
|
||
tracing::debug!(
|
||
elapsed = ?start.elapsed(),
|
||
files = copy_result.stats.files_copied,
|
||
dirs = copy_result.stats.dirs_created,
|
||
symlinks = copy_result.stats.symlinks_copied,
|
||
skipped = copy_result.stats.files_skipped,
|
||
"copying ignored files (ignored-only) complete"
|
||
);
|
||
|
||
Ok(copy_result.stats.into())
|
||
}
|
||
}
|
||
|
||
/// Error context attached when worktree creation fails on a full disk. The
|
||
/// pager matches on it, so this constant is the cross-crate contract.
|
||
pub const OUT_OF_DISK_CONTEXT: &str = "not enough free disk space";
|
||
|
||
/// POSIX disk-full text `git` prints to stderr; the text fallback for the
|
||
/// typed `ErrorKind::StorageFull` check.
|
||
pub const ENOSPC_OS_MESSAGE: &str = "No space left on device";
|
||
|
||
/// Detect a disk-full failure anywhere in an error chain.
|
||
///
|
||
/// Worktree creation touches the disk in many places (reflink/copy of files
|
||
/// and the git index, directory creation, `git worktree add`). When the volume
|
||
/// fills up the underlying `std::io::Error` reports `ErrorKind::StorageFull` —
|
||
/// std maps `ENOSPC` (Linux/macOS) and `ERROR_DISK_FULL` /
|
||
/// `ERROR_HANDLE_DISK_FULL` (Windows) onto it, so this is correct on every
|
||
/// platform. `git` subcommands instead surface the failure only as stderr text.
|
||
fn is_out_of_disk(err: &anyhow::Error) -> bool {
|
||
err.chain().any(|cause| {
|
||
if let Some(io) = cause.downcast_ref::<std::io::Error>()
|
||
&& io.kind() == std::io::ErrorKind::StorageFull
|
||
{
|
||
return true;
|
||
}
|
||
// Fallback for `git` subcommands, which report this only as stderr text.
|
||
cause.to_string().contains(ENOSPC_OS_MESSAGE)
|
||
})
|
||
}
|
||
|
||
/// Promote a disk-full reason to the top of the error chain.
|
||
///
|
||
/// Downstream layers (the workspace hub, ACP) flatten the `anyhow` chain to its
|
||
/// top-level message via `Display`, discarding the root `io::Error`. Without
|
||
/// this, a full disk surfaces to the user as an opaque
|
||
/// `"failed to copy index from … to …"`. Promoting the reason to the outermost
|
||
/// context ensures it survives that flattening; the original chain is preserved
|
||
/// underneath for logs (`{:#}` / `{:?}`).
|
||
fn annotate_disk_full(err: anyhow::Error) -> anyhow::Error {
|
||
if is_out_of_disk(&err) {
|
||
err.context(OUT_OF_DISK_CONTEXT)
|
||
} else {
|
||
err
|
||
}
|
||
}
|
||
|
||
/// Result of removing a worktree.
|
||
#[derive(Clone, Debug)]
|
||
pub struct RemoveReport {
|
||
/// Whether a btrfs subvolume delete was used (O(1)) vs git worktree remove (O(n)).
|
||
pub used_btrfs_delete: bool,
|
||
/// Whether a bind mount was unmounted before deletion.
|
||
pub unmounted_bind: bool,
|
||
/// Whether an overlay mount was unmounted before deletion.
|
||
pub unmounted_overlay: bool,
|
||
}
|
||
|
||
/// Remove a worktree, using the fastest available method.
|
||
///
|
||
/// Detection order:
|
||
/// 1. If the worktree is a symlink/bind-mount to a btrfs snapshot, or a direct btrfs subvolume → unmount if needed + `btrfs subvolume delete` (O(1))
|
||
/// 2. Otherwise → `rm -rf` + deregister from `.git/worktrees/`
|
||
///
|
||
/// **Why not `git worktree remove --force`?** On large repos (100K+ files),
|
||
/// `git worktree remove` walks all files to delete them (often tens of seconds).
|
||
/// Using `rm -rf` + deregistration is ~10x faster because the kernel handles
|
||
/// bulk deletion more efficiently, and we avoid git's per-file validation.
|
||
///
|
||
/// This is a **blocking** operation. Callers should use `spawn_blocking`
|
||
/// when calling from async contexts.
|
||
pub fn remove_worktree(worktree_path: &std::path::Path) -> Result<RemoveReport> {
|
||
remove_worktree_inner(worktree_path, None)
|
||
}
|
||
|
||
/// Remove a worktree with an optional delegate for privileged btrfs operations.
|
||
///
|
||
/// When the caller has a `BtrfsDelegate` (e.g., from a sandbox with IPC to a
|
||
/// privileged helper), this function uses it as a fallback when direct btrfs
|
||
/// operations fail (e.g., due to missing `CAP_SYS_ADMIN`).
|
||
pub fn remove_worktree_with_delegate(
|
||
worktree_path: &std::path::Path,
|
||
delegate: Option<Arc<dyn BtrfsDelegate>>,
|
||
) -> Result<RemoveReport> {
|
||
remove_worktree_inner(worktree_path, delegate.as_ref())
|
||
}
|
||
|
||
fn remove_worktree_inner(
|
||
worktree_path: &std::path::Path,
|
||
delegate: Option<&Arc<dyn BtrfsDelegate>>,
|
||
) -> Result<RemoveReport> {
|
||
let report = remove_worktree_from_disk(worktree_path, delegate)?;
|
||
|
||
// Unregister only AFTER a successful on-disk removal: a failed removal (e.g.
|
||
// EPERM on btrfs delete) must keep the record so the worktree stays tracked
|
||
// by list/gc instead of leaking untracked on disk.
|
||
#[cfg(feature = "metadata")]
|
||
unregister_worktree(worktree_path);
|
||
|
||
Ok(report)
|
||
}
|
||
|
||
/// Remove the worktree from disk (overlay/btrfs/metadata fast paths or `rm -rf`
|
||
/// + deregister), without touching the metadata DB. Returns `Err` if the on-disk
|
||
/// removal fails, so the caller can keep the DB record.
|
||
fn remove_worktree_from_disk(
|
||
worktree_path: &std::path::Path,
|
||
delegate: Option<&Arc<dyn BtrfsDelegate>>,
|
||
) -> Result<RemoveReport> {
|
||
use anyhow::Context;
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
let _ = delegate;
|
||
|
||
// Try overlay removal first (Linux only) — unmount overlay + delete btrfs snapshot
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
if let Some(report) = try_overlay_remove(worktree_path, delegate)? {
|
||
return Ok(report);
|
||
}
|
||
}
|
||
|
||
// Try btrfs metadata-based removal (crash recovery)
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
if let Some(report) = try_btrfs_remove_from_metadata(worktree_path, delegate)? {
|
||
return Ok(report);
|
||
}
|
||
}
|
||
|
||
// Try btrfs fast path (Linux only)
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
if let Some(report) = try_btrfs_remove(worktree_path, delegate)? {
|
||
return Ok(report);
|
||
}
|
||
}
|
||
|
||
// Fast path: rm -rf the worktree directory, then deregister from .git/worktrees/.
|
||
// This is ~10x faster than `git worktree remove --force` on large repos.
|
||
tracing::debug!(
|
||
path = %worktree_path.display(),
|
||
"removing worktree via rm -rf + deregister"
|
||
);
|
||
|
||
// Read the worktree's .git file to find the registration dir BEFORE deleting.
|
||
// Linked worktrees have `.git` as a file containing `gitdir: /path/to/.git/worktrees/<name>`.
|
||
let registration_dir = read_worktree_gitdir(worktree_path);
|
||
|
||
// symlink_metadata, not `exists()` (which follows the link): a worktree
|
||
// exposed as a symlink — including a now-dangling one — must be unlinked, not
|
||
// skipped. (On Linux, symlinks are normally handled earlier in try_btrfs_remove.)
|
||
match std::fs::symlink_metadata(worktree_path) {
|
||
Ok(md) if md.file_type().is_symlink() => {
|
||
std::fs::remove_file(worktree_path).context(format!(
|
||
"failed to remove worktree symlink: {}",
|
||
worktree_path.display()
|
||
))?;
|
||
}
|
||
Ok(_) => {
|
||
std::fs::remove_dir_all(worktree_path).context(format!(
|
||
"failed to remove worktree directory: {}",
|
||
worktree_path.display()
|
||
))?;
|
||
}
|
||
Err(_) => {} // nothing at the path
|
||
}
|
||
|
||
// Deregister: remove the `.git/worktrees/<name>/` directory.
|
||
// This is what `git worktree remove` does after deleting the working tree.
|
||
if let Some(reg_dir) = registration_dir
|
||
&& reg_dir.exists()
|
||
{
|
||
tracing::debug!(
|
||
registration_dir = %reg_dir.display(),
|
||
"removing worktree registration from .git/worktrees/"
|
||
);
|
||
let _ = std::fs::remove_dir_all(®_dir);
|
||
}
|
||
|
||
Ok(RemoveReport {
|
||
used_btrfs_delete: false,
|
||
unmounted_bind: false,
|
||
unmounted_overlay: false,
|
||
})
|
||
}
|
||
|
||
/// Report from cleaning up multiple worktrees.
|
||
#[derive(Debug, Default)]
|
||
pub struct CleanupReport {
|
||
/// Number of worktrees successfully removed.
|
||
pub removed: u64,
|
||
/// Number of overlay mounts unmounted.
|
||
pub overlays_unmounted: u64,
|
||
/// Number of btrfs subvolumes deleted.
|
||
pub btrfs_deleted: u64,
|
||
/// Number of errors encountered (worktrees that couldn't be removed).
|
||
pub errors: u64,
|
||
}
|
||
|
||
/// Remove all worktrees under a directory.
|
||
///
|
||
/// Scans the given directory for subdirectories (one or two levels deep to
|
||
/// handle `~/.grok/worktrees/<repo>/<session>/`) and calls `remove_worktree()`
|
||
/// on each. Useful during session teardown to clean up all session worktrees.
|
||
///
|
||
/// This is a **blocking** operation.
|
||
pub fn cleanup_worktrees_in(dir: &std::path::Path) -> CleanupReport {
|
||
cleanup_worktrees_in_with_delegate(dir, None)
|
||
}
|
||
|
||
/// Remove all worktrees under a directory, using an optional delegate for
|
||
/// privileged btrfs operations.
|
||
///
|
||
/// Like `cleanup_worktrees_in`, but forwards the delegate to each
|
||
/// `remove_worktree_with_delegate` call so that rootless hosts can clean up
|
||
/// btrfs snapshots via a privileged helper.
|
||
pub fn cleanup_worktrees_in_with_delegate(
|
||
dir: &std::path::Path,
|
||
delegate: Option<Arc<dyn BtrfsDelegate>>,
|
||
) -> CleanupReport {
|
||
let mut report = CleanupReport::default();
|
||
|
||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||
tracing::debug!(dir = %dir.display(), "cleanup: directory not readable");
|
||
return report;
|
||
};
|
||
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
// symlink_metadata so a symlink-exposed worktree (btrfs snapshot layout),
|
||
// including a now-dangling one, is handled — `is_dir()` follows the link
|
||
// and returns false for a broken symlink, leaking it.
|
||
let Ok(md) = path.symlink_metadata() else {
|
||
continue;
|
||
};
|
||
if md.file_type().is_symlink() {
|
||
// remove_worktree handles the snapshot delete + symlink unlink.
|
||
cleanup_single_worktree(&path, delegate.as_ref(), &mut report);
|
||
continue;
|
||
}
|
||
if !md.is_dir() {
|
||
continue;
|
||
}
|
||
|
||
let has_git = path.join(".git").exists();
|
||
|
||
if has_git {
|
||
cleanup_single_worktree(&path, delegate.as_ref(), &mut report);
|
||
} else {
|
||
if let Ok(sub_entries) = std::fs::read_dir(&path) {
|
||
for sub_entry in sub_entries.flatten() {
|
||
let sub_path = sub_entry.path();
|
||
if let Ok(sub_md) = sub_path.symlink_metadata()
|
||
&& (sub_md.file_type().is_symlink() || sub_md.is_dir())
|
||
{
|
||
cleanup_single_worktree(&sub_path, delegate.as_ref(), &mut report);
|
||
}
|
||
}
|
||
}
|
||
let _ = std::fs::remove_dir(&path);
|
||
}
|
||
}
|
||
|
||
tracing::info!(
|
||
dir = %dir.display(),
|
||
removed = report.removed,
|
||
overlays = report.overlays_unmounted,
|
||
btrfs = report.btrfs_deleted,
|
||
errors = report.errors,
|
||
"worktree cleanup complete"
|
||
);
|
||
|
||
report
|
||
}
|
||
|
||
/// Remove a single worktree and update the report.
|
||
fn cleanup_single_worktree(
|
||
path: &std::path::Path,
|
||
delegate: Option<&Arc<dyn BtrfsDelegate>>,
|
||
report: &mut CleanupReport,
|
||
) {
|
||
match remove_worktree_inner(path, delegate) {
|
||
Ok(r) => {
|
||
report.removed += 1;
|
||
if r.unmounted_overlay {
|
||
report.overlays_unmounted += 1;
|
||
}
|
||
if r.used_btrfs_delete {
|
||
report.btrfs_deleted += 1;
|
||
}
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
path = %path.display(),
|
||
error = %e,
|
||
"failed to clean up worktree"
|
||
);
|
||
report.errors += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Scan known overlay roots under `/local/repo-fuse-*/worktrees/` for orphaned
|
||
/// overlay snapshots.
|
||
///
|
||
/// An overlay snapshot is orphaned if its metadata file exists but the
|
||
/// `mount_target` doesn't exist or isn't mounted. For each orphan: delete
|
||
/// the btrfs snapshot, remove the work dir, and clean up metadata.
|
||
///
|
||
/// Intended for host startup / periodic cleanup of leftovers from unclean
|
||
/// exits.
|
||
///
|
||
/// This is a **blocking** operation.
|
||
#[cfg(target_os = "linux")]
|
||
pub fn cleanup_orphaned_overlay_snapshots() -> CleanupReport {
|
||
crate::overlay::cleanup_orphaned_overlay_snapshots()
|
||
}
|
||
|
||
/// Try to remove an overlay worktree.
|
||
/// Returns `Ok(Some(report))` if overlay was detected and removed, `Ok(None)` to fall back.
|
||
#[cfg(target_os = "linux")]
|
||
fn try_overlay_remove(
|
||
worktree_path: &std::path::Path,
|
||
delegate: Option<&Arc<dyn BtrfsDelegate>>,
|
||
) -> Result<Option<RemoveReport>> {
|
||
use crate::overlay;
|
||
|
||
// Method 1: Check live mountinfo
|
||
if let Some(report) = overlay::try_remove_from_mountinfo(worktree_path, delegate)? {
|
||
return Ok(Some(report));
|
||
}
|
||
|
||
// Method 2: Check persisted metadata (crash recovery)
|
||
if let Some(report) = overlay::try_remove_from_metadata(worktree_path, delegate)? {
|
||
return Ok(Some(report));
|
||
}
|
||
|
||
Ok(None)
|
||
}
|
||
|
||
/// Read the `gitdir:` pointer from a linked worktree's `.git` file.
|
||
///
|
||
/// Linked worktrees have `.git` as a plain file containing:
|
||
/// ```text
|
||
/// gitdir: /path/to/main-repo/.git/worktrees/<name>
|
||
/// ```
|
||
///
|
||
/// Returns the resolved path to the registration directory, or `None`
|
||
/// if the worktree doesn't have a `.git` file (standalone repo or missing).
|
||
fn read_worktree_gitdir(worktree_path: &std::path::Path) -> Option<std::path::PathBuf> {
|
||
let git_file = worktree_path.join(".git");
|
||
let content = std::fs::read_to_string(&git_file).ok()?;
|
||
let gitdir = content.trim().strip_prefix("gitdir: ")?;
|
||
let path = std::path::Path::new(gitdir);
|
||
// Resolve relative paths against the worktree directory
|
||
let resolved = if path.is_relative() {
|
||
worktree_path.join(path)
|
||
} else {
|
||
path.to_path_buf()
|
||
};
|
||
// Canonicalize to clean up any `..` components
|
||
dunce::canonicalize(&resolved).ok().or(Some(resolved))
|
||
}
|
||
|
||
/// Delete `snapshot_path`, falling back to the delegate's `delete_snapshot`
|
||
/// (keyed by `worktree_path`) when the direct btrfs delete fails — e.g. EPERM on
|
||
/// a rootless host (no `CAP_SYS_ADMIN`) where only a privileged helper can run
|
||
/// `btrfs subvolume delete`.
|
||
///
|
||
/// `Some` means the delegate handled it; `None` means the direct delete succeeded
|
||
/// and the caller still owns local cleanup.
|
||
#[cfg(target_os = "linux")]
|
||
fn delete_snapshot_with_delegate_fallback(
|
||
snapshot_path: &std::path::Path,
|
||
worktree_path: &std::path::Path,
|
||
delegate: Option<&Arc<dyn BtrfsDelegate>>,
|
||
delete: impl FnOnce(&std::path::Path) -> Result<()>,
|
||
) -> Result<Option<RemoveReport>> {
|
||
let Err(e) = delete(snapshot_path) else {
|
||
return Ok(None);
|
||
};
|
||
if let Some(delegate) = delegate {
|
||
tracing::info!(
|
||
path = %worktree_path.display(),
|
||
"btrfs subvolume delete failed, trying delegate"
|
||
);
|
||
match delegate.delete_snapshot(worktree_path) {
|
||
Ok(report) => return Ok(Some(report)),
|
||
Err(delegate_err) => {
|
||
tracing::warn!(error = %delegate_err, "delegate deletion also failed");
|
||
}
|
||
}
|
||
}
|
||
Err(e)
|
||
}
|
||
|
||
/// Try to remove a worktree using btrfs subvolume delete.
|
||
/// Returns `Ok(Some(report))` if btrfs was used, `Ok(None)` to fall back to git.
|
||
///
|
||
/// Handles three cases:
|
||
/// 1. **Symlinked worktree** (delegate path): `worktree_path` is a symlink to a
|
||
/// btrfs snapshot. Delete the snapshot, then remove the symlink.
|
||
/// 2. **Bind-mounted worktree**: `worktree_path` is a bind mount from a btrfs
|
||
/// snapshot. Unmount, then delete the snapshot subvolume.
|
||
/// 3. **Direct btrfs worktree**: `worktree_path` itself is the btrfs subvolume.
|
||
/// Delete it directly.
|
||
#[cfg(target_os = "linux")]
|
||
fn try_btrfs_remove(
|
||
worktree_path: &std::path::Path,
|
||
delegate: Option<&Arc<dyn BtrfsDelegate>>,
|
||
) -> Result<Option<RemoveReport>> {
|
||
use crate::btrfs;
|
||
use anyhow::Context;
|
||
|
||
// Case 1: Symlink to a btrfs snapshot (created by the delegate path on
|
||
// rootless hosts). Symlinks cross mount namespaces — this is the
|
||
// counterpart to the privileged helper's symlink creation.
|
||
if worktree_path.is_symlink() {
|
||
let link_target = match std::fs::read_link(worktree_path) {
|
||
Ok(t) => t,
|
||
// Broken/unreadable symlink: unlink it so it isn't left dangling
|
||
// (the `rm -rf` fallback follows the dead link and would miss it).
|
||
Err(_) => {
|
||
let _ = std::fs::remove_file(worktree_path);
|
||
return Ok(None);
|
||
}
|
||
};
|
||
|
||
let resolved = if link_target.is_relative() {
|
||
worktree_path
|
||
.parent()
|
||
.unwrap_or(std::path::Path::new("/"))
|
||
.join(&link_target)
|
||
} else {
|
||
link_target
|
||
};
|
||
|
||
if let Ok(Some(_)) = btrfs::is_btrfs_subvolume(&resolved) {
|
||
// Refuse to follow a confused/planted symlink into deleting a
|
||
// subvolume outside the snapshot storage (e.g. the live source repo).
|
||
// The symlink itself is just a pointer, so removing it is always safe.
|
||
if !btrfs::is_safe_snapshot_delete_target(&resolved) {
|
||
tracing::warn!(
|
||
symlink = %worktree_path.display(),
|
||
target = %resolved.display(),
|
||
"refusing to delete subvolume outside snapshot storage; removing only the symlink"
|
||
);
|
||
let _ = std::fs::remove_file(worktree_path);
|
||
return Ok(Some(RemoveReport {
|
||
used_btrfs_delete: false,
|
||
unmounted_bind: false,
|
||
unmounted_overlay: false,
|
||
}));
|
||
}
|
||
|
||
tracing::info!(
|
||
symlink = %worktree_path.display(),
|
||
target = %resolved.display(),
|
||
"removing symlinked btrfs worktree"
|
||
);
|
||
|
||
// Delete snapshot first — if this fails, the symlink still
|
||
// references it so cleanup can be retried.
|
||
//
|
||
// Known residual TOCTOU: validation `lstat`s/canonicalizes then we
|
||
// delete by path (the `btrfs subvolume delete` CLI takes a path, not
|
||
// an fd, so there is no `unlinkat` to close the window). Bounded by:
|
||
// `btrfs` refuses non-subvolumes, the snapshot dir is grok-owned, and
|
||
// `..`/symlink targets are already rejected. Accepted as-is.
|
||
if let Some(report) = delete_snapshot_with_delegate_fallback(
|
||
&resolved,
|
||
worktree_path,
|
||
delegate,
|
||
btrfs::delete_snapshot,
|
||
)? {
|
||
return Ok(Some(report));
|
||
}
|
||
btrfs::remove_btrfs_metadata(&resolved);
|
||
let _ = std::fs::remove_file(worktree_path);
|
||
|
||
return Ok(Some(RemoveReport {
|
||
used_btrfs_delete: true,
|
||
unmounted_bind: false,
|
||
unmounted_overlay: false,
|
||
}));
|
||
}
|
||
|
||
// Symlink to non-btrfs target — remove symlink, fall through.
|
||
let _ = std::fs::remove_file(worktree_path);
|
||
}
|
||
|
||
// Case 2 & 3: Check if the worktree path is a btrfs subvolume.
|
||
let btrfs_info = match btrfs::is_btrfs_subvolume(worktree_path) {
|
||
Ok(Some(info)) => info,
|
||
Ok(None) => return Ok(None), // Not a btrfs subvolume, fall back
|
||
Err(e) => {
|
||
tracing::debug!(
|
||
path = %worktree_path.display(),
|
||
error = %e,
|
||
"btrfs detection failed, falling back to git worktree remove"
|
||
);
|
||
return Ok(None);
|
||
}
|
||
};
|
||
|
||
tracing::info!(
|
||
path = %worktree_path.display(),
|
||
bind_mount = ?btrfs_info.bind_mount_source,
|
||
"removing worktree via btrfs subvolume delete (O(1))"
|
||
);
|
||
|
||
let mut unmounted_bind = false;
|
||
|
||
// Case 2: Legacy bind mount — unmount first, then delete snapshot.
|
||
if btrfs_info.bind_mount_source.is_some() {
|
||
let mut umount_cmd = std::process::Command::new("umount");
|
||
xai_tty_utils::detach_std_command(&mut umount_cmd);
|
||
umount_cmd.stdin(std::process::Stdio::null());
|
||
let output = umount_cmd
|
||
.arg(worktree_path)
|
||
.output()
|
||
.context("failed to execute umount")?;
|
||
|
||
if output.status.success() {
|
||
unmounted_bind = true;
|
||
let _ = std::fs::remove_dir(worktree_path);
|
||
} else {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
tracing::warn!(
|
||
path = %worktree_path.display(),
|
||
stderr = %stderr.trim(),
|
||
"umount failed, attempting direct snapshot deletion"
|
||
);
|
||
// Don't return — proceed to delete the snapshot directly.
|
||
// The mount point may be stale after an unclean host restart.
|
||
}
|
||
}
|
||
|
||
// Delete the btrfs subvolume (the actual snapshot)
|
||
let snapshot_path = btrfs_info
|
||
.bind_mount_source
|
||
.as_deref()
|
||
.unwrap_or(worktree_path);
|
||
|
||
// Reuse the hardened `btrfs::delete_snapshot` (OsStr args, no lossy
|
||
// `.`-default) rather than re-spawning the command inline.
|
||
if let Some(report) = delete_snapshot_with_delegate_fallback(
|
||
snapshot_path,
|
||
worktree_path,
|
||
delegate,
|
||
btrfs::delete_snapshot,
|
||
)? {
|
||
return Ok(Some(report));
|
||
}
|
||
|
||
btrfs::remove_btrfs_metadata(snapshot_path);
|
||
|
||
tracing::info!(
|
||
path = %worktree_path.display(),
|
||
"btrfs subvolume deleted successfully"
|
||
);
|
||
|
||
Ok(Some(RemoveReport {
|
||
used_btrfs_delete: true,
|
||
unmounted_bind,
|
||
unmounted_overlay: false,
|
||
}))
|
||
}
|
||
|
||
/// Try to remove via persisted btrfs snapshot metadata (crash recovery).
|
||
///
|
||
/// Scans btrfs mount points for `*.btrfs-meta.json` files whose
|
||
/// `mount_target` matches `target`. Works even after the bind mount is gone.
|
||
#[cfg(target_os = "linux")]
|
||
fn try_btrfs_remove_from_metadata(
|
||
target: &std::path::Path,
|
||
delegate: Option<&Arc<dyn BtrfsDelegate>>,
|
||
) -> Result<Option<RemoveReport>> {
|
||
let mount_entries = match crate::mount_info::parse_mountinfo() {
|
||
Ok(e) => e,
|
||
Err(_) => return Ok(None),
|
||
};
|
||
|
||
try_btrfs_remove_from_metadata_inner(target, &mount_entries, delegate)
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
fn try_btrfs_remove_from_metadata_inner(
|
||
target: &std::path::Path,
|
||
mount_entries: &[crate::mount_info::MountEntry],
|
||
delegate: Option<&Arc<dyn BtrfsDelegate>>,
|
||
) -> Result<Option<RemoveReport>> {
|
||
use crate::btrfs;
|
||
|
||
for entry in mount_entries {
|
||
if entry.fs_type != "btrfs" {
|
||
continue;
|
||
}
|
||
|
||
for subdir in btrfs::BTRFS_SNAPSHOT_SUBDIRS {
|
||
let dir = entry.mount_point.join(subdir);
|
||
let Ok(dir_entries) = std::fs::read_dir(&dir) else {
|
||
continue;
|
||
};
|
||
|
||
for dir_entry in dir_entries.flatten() {
|
||
let name = dir_entry.file_name();
|
||
let name_str = name.to_string_lossy();
|
||
if !name_str.ends_with(btrfs::BTRFS_META_SUFFIX) {
|
||
continue;
|
||
}
|
||
|
||
let meta_path = dir_entry.path();
|
||
let Ok(content) = std::fs::read_to_string(&meta_path) else {
|
||
continue;
|
||
};
|
||
let Ok(meta) = serde_json::from_str::<btrfs::BtrfsSnapshotMetadata>(&content)
|
||
else {
|
||
continue;
|
||
};
|
||
|
||
if meta.mount_target != target {
|
||
continue;
|
||
}
|
||
|
||
tracing::info!(
|
||
target = %target.display(),
|
||
snapshot = %meta.snapshot_path.display(),
|
||
"found btrfs snapshot metadata for worktree"
|
||
);
|
||
|
||
// `meta.snapshot_path` comes from an attacker-controllable
|
||
// metadata file. Only delete it when it is a contained snapshot
|
||
// subvolume located directly inside the directory we scanned.
|
||
let snapshot_contained = meta.snapshot_path.parent() == Some(dir.as_path())
|
||
&& btrfs::is_safe_snapshot_delete_target(&meta.snapshot_path);
|
||
|
||
let target_is_symlink = target.is_symlink();
|
||
let mut unmounted = false;
|
||
|
||
// A legacy bind-mount directory must be unmounted before its
|
||
// snapshot subvolume can be deleted; a symlink needs no umount.
|
||
if !target_is_symlink {
|
||
let mut umount_cmd = std::process::Command::new("umount");
|
||
xai_tty_utils::detach_std_command(&mut umount_cmd);
|
||
umount_cmd.stdin(std::process::Stdio::null());
|
||
if let Ok(output) = umount_cmd.arg(target).output() {
|
||
unmounted = output.status.success();
|
||
}
|
||
}
|
||
|
||
// Delete the snapshot BEFORE removing the worktree reference, so
|
||
// the link/dir still points at it if deletion fails (retriable) —
|
||
// consistent with `try_btrfs_remove` Case 1.
|
||
let mut deleted = false;
|
||
let mut refused = false;
|
||
if meta.snapshot_path.exists() {
|
||
if snapshot_contained {
|
||
if let Err(e) = btrfs::delete_snapshot(&meta.snapshot_path) {
|
||
// Try delegate fallback for sandboxed/rootless setups.
|
||
if let Some(delegate) = delegate {
|
||
tracing::info!(
|
||
path = %meta.snapshot_path.display(),
|
||
"btrfs delete failed in metadata path, trying delegate"
|
||
);
|
||
match delegate.delete_snapshot(target) {
|
||
Ok(report) => return Ok(Some(report)),
|
||
Err(delegate_err) => {
|
||
tracing::warn!(
|
||
error = %delegate_err,
|
||
"delegate deletion also failed in metadata path"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
return Err(e);
|
||
}
|
||
deleted = true;
|
||
} else {
|
||
refused = true;
|
||
tracing::warn!(
|
||
snapshot = %meta.snapshot_path.display(),
|
||
dir = %dir.display(),
|
||
"refusing to delete btrfs snapshot referenced by metadata: \
|
||
path is outside the scanned snapshot storage; preserving metadata"
|
||
);
|
||
}
|
||
}
|
||
|
||
// Remove the worktree reference (symlink file or empty dir). The
|
||
// pointer is always safe to drop regardless of the refusal above.
|
||
if target_is_symlink {
|
||
let _ = std::fs::remove_file(target);
|
||
} else {
|
||
let _ = std::fs::remove_dir(target);
|
||
}
|
||
|
||
// Discard the metadata only when we handled the snapshot (deleted
|
||
// it, or it was already gone). On refusal, keep it so the orphan
|
||
// scanner can retry / it can be inspected.
|
||
if !refused {
|
||
let _ = std::fs::remove_file(&meta_path);
|
||
}
|
||
|
||
return Ok(Some(RemoveReport {
|
||
used_btrfs_delete: deleted,
|
||
unmounted_bind: unmounted,
|
||
unmounted_overlay: false,
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(None)
|
||
}
|
||
|
||
/// Scan btrfs mount points for orphaned direct btrfs snapshots.
|
||
///
|
||
/// A btrfs snapshot is orphaned if its metadata file exists but the
|
||
/// `mount_target` is not an active mount point. For each orphan: unmount
|
||
/// stale target, delete the btrfs snapshot, and remove metadata.
|
||
///
|
||
/// This is the btrfs counterpart to `cleanup_orphaned_overlay_snapshots()`.
|
||
#[cfg(target_os = "linux")]
|
||
pub fn cleanup_orphaned_btrfs_snapshots() -> CleanupReport {
|
||
let mount_entries = match crate::mount_info::parse_mountinfo() {
|
||
Ok(e) => e,
|
||
Err(_) => return CleanupReport::default(),
|
||
};
|
||
|
||
cleanup_orphaned_btrfs_snapshots_inner(&mount_entries)
|
||
}
|
||
|
||
/// Whether the symlink at `link` resolves to `target`.
|
||
///
|
||
/// Returns `false` when `link` is not a symlink or cannot be read. Used to
|
||
/// recognize a **live** symlink worktree (current layout) whose `mount_target`
|
||
/// never appears in mountinfo, so the orphan scanner does not destroy it.
|
||
#[cfg(target_os = "linux")]
|
||
fn symlink_resolves_to(link: &std::path::Path, target: &std::path::Path) -> bool {
|
||
if !link.is_symlink() {
|
||
return false;
|
||
}
|
||
match std::fs::read_link(link) {
|
||
Ok(t) if t.is_relative() => {
|
||
link.parent().unwrap_or(std::path::Path::new("/")).join(t) == target
|
||
}
|
||
Ok(t) => t == target,
|
||
Err(_) => false,
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
fn cleanup_orphaned_btrfs_snapshots_inner(
|
||
mount_entries: &[crate::mount_info::MountEntry],
|
||
) -> CleanupReport {
|
||
use crate::btrfs;
|
||
|
||
let mut report = CleanupReport::default();
|
||
|
||
for mount_point in mount_entries
|
||
.iter()
|
||
.filter(|e| e.fs_type == "btrfs")
|
||
.map(|e| &e.mount_point)
|
||
{
|
||
for subdir in btrfs::BTRFS_SNAPSHOT_SUBDIRS {
|
||
let dir = mount_point.join(subdir);
|
||
let Ok(dir_entries) = std::fs::read_dir(&dir) else {
|
||
continue;
|
||
};
|
||
|
||
for dir_entry in dir_entries.flatten() {
|
||
let name = dir_entry.file_name();
|
||
let name_str = name.to_string_lossy();
|
||
if !name_str.ends_with(btrfs::BTRFS_META_SUFFIX) {
|
||
continue;
|
||
}
|
||
|
||
let meta_path = dir_entry.path();
|
||
let Ok(content) = std::fs::read_to_string(&meta_path) else {
|
||
let _ = std::fs::remove_file(&meta_path);
|
||
continue;
|
||
};
|
||
let Ok(meta) = serde_json::from_str::<btrfs::BtrfsSnapshotMetadata>(&content)
|
||
else {
|
||
let _ = std::fs::remove_file(&meta_path);
|
||
continue;
|
||
};
|
||
|
||
// A live worktree is active either as a bind mount (appears in
|
||
// mountinfo) or as a symlink resolving to its snapshot (the
|
||
// current layout — `mount_target` is a symlink, never in
|
||
// mountinfo). Both must be treated as active, not orphaned.
|
||
let is_active = mount_entries
|
||
.iter()
|
||
.any(|e| e.mount_point == meta.mount_target)
|
||
|| symlink_resolves_to(&meta.mount_target, &meta.snapshot_path);
|
||
|
||
if is_active {
|
||
tracing::debug!(
|
||
snapshot = %meta.snapshot_path.display(),
|
||
target = %meta.mount_target.display(),
|
||
"skipping active btrfs snapshot"
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// If the mount_target's parent dir is missing we cannot prove the snapshot
|
||
// is orphaned: this scanner runs before restore recreates worktree dirs, so a
|
||
// snapshot about to be re-exposed would be wrongly destroyed. Skipping at worst
|
||
// leaks a true orphan (reclaimed on a later cycle) — strictly safer than deleting.
|
||
if let Some(parent) = meta.mount_target.parent()
|
||
&& !parent.exists()
|
||
{
|
||
tracing::debug!(
|
||
snapshot = %meta.snapshot_path.display(),
|
||
target = %meta.mount_target.display(),
|
||
"skipping btrfs snapshot: mount_target parent missing (cannot prove orphaned)"
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// Untrusted metadata: only delete a snapshot contained directly
|
||
// in the directory we scanned. Leave anything else (and its
|
||
// metadata) untouched for inspection.
|
||
let snapshot_contained = meta.snapshot_path.parent() == Some(dir.as_path())
|
||
&& btrfs::is_safe_snapshot_delete_target(&meta.snapshot_path);
|
||
if meta.snapshot_path.exists() && !snapshot_contained {
|
||
tracing::warn!(
|
||
snapshot = %meta.snapshot_path.display(),
|
||
dir = %dir.display(),
|
||
"refusing to delete btrfs snapshot outside scanned storage"
|
||
);
|
||
report.errors += 1;
|
||
continue;
|
||
}
|
||
|
||
tracing::info!(
|
||
target = %meta.mount_target.display(),
|
||
snapshot = %meta.snapshot_path.display(),
|
||
"cleaning up orphaned btrfs snapshot"
|
||
);
|
||
|
||
// Remove the worktree reference: a symlink is unlinked; a legacy
|
||
// bind-mount dir is unmounted then removed.
|
||
if meta.mount_target.is_symlink() {
|
||
let _ = std::fs::remove_file(&meta.mount_target);
|
||
} else {
|
||
let mut umount_cmd = std::process::Command::new("umount");
|
||
xai_tty_utils::detach_std_command(&mut umount_cmd);
|
||
umount_cmd.stdin(std::process::Stdio::null());
|
||
let _ = umount_cmd.arg(&meta.mount_target).output();
|
||
let _ = std::fs::remove_dir(&meta.mount_target);
|
||
}
|
||
|
||
if meta.snapshot_path.exists() {
|
||
if let Err(e) = btrfs::delete_snapshot(&meta.snapshot_path) {
|
||
tracing::warn!(
|
||
path = %meta.snapshot_path.display(),
|
||
error = %e,
|
||
"failed to delete orphaned btrfs snapshot"
|
||
);
|
||
report.errors += 1;
|
||
// Preserve metadata so the orphan scanner can retry
|
||
// on the next cycle instead of losing track of it.
|
||
continue;
|
||
} else {
|
||
report.btrfs_deleted += 1;
|
||
}
|
||
}
|
||
|
||
let _ = std::fs::remove_file(&meta_path);
|
||
report.removed += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
if report.removed > 0 || report.errors > 0 {
|
||
tracing::info!(
|
||
removed = report.removed,
|
||
btrfs = report.btrfs_deleted,
|
||
errors = report.errors,
|
||
"orphaned btrfs snapshot cleanup complete"
|
||
);
|
||
}
|
||
|
||
report
|
||
}
|
||
|
||
#[cfg(feature = "metadata")]
|
||
pub(crate) fn register_worktree(
|
||
worktree_path: &std::path::Path,
|
||
source: &std::path::Path,
|
||
kind: crate::db::WorktreeKind,
|
||
creation_mode: &str,
|
||
git_ref: &str,
|
||
commit: &str,
|
||
session_id: Option<String>,
|
||
worktree_id: Option<String>,
|
||
metadata: Option<serde_json::Value>,
|
||
) {
|
||
use crate::db;
|
||
|
||
let db = match db::WorktreeDb::open_default() {
|
||
Ok(db) => db,
|
||
Err(e) => {
|
||
tracing::warn!(error = %e, "failed to open worktree DB for registration");
|
||
return;
|
||
}
|
||
};
|
||
// Same canonical path as discovery rebuild / WorktreeDb::get so macOS
|
||
// /var vs /private/var (and other symlink roots) do not create duplicate rows.
|
||
let path = dunce::canonicalize(worktree_path).unwrap_or_else(|_| worktree_path.to_path_buf());
|
||
let source = dunce::canonicalize(source).unwrap_or_else(|_| source.to_path_buf());
|
||
let record = db::WorktreeRecord {
|
||
id: worktree_id.unwrap_or_else(|| db::id_from_path(&path)),
|
||
path,
|
||
source_repo: source.clone(),
|
||
repo_name: db::repo_name_from_path(&source),
|
||
kind,
|
||
creation_mode: creation_mode.to_owned(),
|
||
git_ref: Some(git_ref.to_owned()),
|
||
head_commit: Some(commit.to_owned()),
|
||
session_id,
|
||
creator_pid: Some(std::process::id()),
|
||
created_at: db::now_epoch_secs(),
|
||
last_accessed_at: None,
|
||
status: db::WorktreeStatus::Alive,
|
||
metadata,
|
||
};
|
||
if let Err(e) = db.register(&record) {
|
||
tracing::warn!(error = %e, "failed to register worktree in DB");
|
||
}
|
||
}
|
||
|
||
#[cfg(feature = "metadata")]
|
||
fn unregister_worktree(worktree_path: &std::path::Path) {
|
||
if let Ok(db) = crate::db::WorktreeDb::open_default() {
|
||
let path =
|
||
dunce::canonicalize(worktree_path).unwrap_or_else(|_| worktree_path.to_path_buf());
|
||
let _ = db.unregister_by_path(&path);
|
||
}
|
||
}
|
||
|
||
/// Test-only `BtrfsDelegate` that returns a fixed snapshot and counts
|
||
/// `delete_snapshot` calls. Shared by the delegate-arm reclaim tests
|
||
/// (`worktree::execute`) and the gc-with-delegate tests.
|
||
#[cfg(test)]
|
||
pub(crate) struct RecordingDelegate {
|
||
pub snapshot_path: PathBuf,
|
||
pub worktree_path: PathBuf,
|
||
pub deletes: Arc<std::sync::atomic::AtomicUsize>,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
impl BtrfsDelegate for RecordingDelegate {
|
||
fn create_snapshot(&self, _source: &Path, _dest: &Path) -> Result<DelegateSnapshotResult> {
|
||
Ok(DelegateSnapshotResult {
|
||
snapshot_path: self.snapshot_path.clone(),
|
||
worktree_path: self.worktree_path.clone(),
|
||
bind_mounted: false,
|
||
})
|
||
}
|
||
|
||
fn delete_snapshot(&self, _worktree_path: &Path) -> Result<RemoveReport> {
|
||
self.deletes
|
||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
Ok(RemoveReport {
|
||
used_btrfs_delete: true,
|
||
unmounted_bind: false,
|
||
unmounted_overlay: false,
|
||
})
|
||
}
|
||
}
|
||
|
||
#[cfg(feature = "metadata")]
|
||
pub mod gc {
|
||
use std::collections::BTreeMap;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
|
||
use anyhow::Result;
|
||
|
||
use crate::BtrfsDelegate;
|
||
use crate::db::{ListFilter, WorktreeDb, WorktreeKind, WorktreeStatus};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
pub struct GcOptions {
|
||
/// Default max age for kinds not in [`Self::max_age_by_kind`].
|
||
/// `None` + empty map disables the age path.
|
||
pub max_age_secs: Option<i64>,
|
||
pub force: bool,
|
||
pub dry_run: bool,
|
||
/// Paths that must not be age-expired (auto path: process cwd).
|
||
#[serde(default)]
|
||
pub protect_paths: Vec<PathBuf>,
|
||
/// Age-path only; equivalent to `max_age_by_kind[kind] = None`.
|
||
/// Honored even when `force=true`. Dead-path unregister still applies.
|
||
#[serde(default)]
|
||
pub skip_kinds: Vec<WorktreeKind>,
|
||
/// Per-kind override of [`Self::max_age_secs`]. `None` = never age-expire.
|
||
/// `force` does not override never-expire.
|
||
#[serde(default)]
|
||
pub max_age_by_kind: BTreeMap<WorktreeKind, Option<i64>>,
|
||
}
|
||
|
||
/// `Some(secs)` to age-expire; `None` = never. Order: skip_kinds → map → max_age_secs.
|
||
pub fn effective_max_age(opts: &GcOptions, kind: WorktreeKind) -> Option<i64> {
|
||
if opts.skip_kinds.contains(&kind) {
|
||
return None;
|
||
}
|
||
opts.max_age_by_kind
|
||
.get(&kind)
|
||
.copied()
|
||
.unwrap_or(opts.max_age_secs)
|
||
}
|
||
|
||
pub(crate) fn age_path_enabled(opts: &GcOptions) -> bool {
|
||
opts.max_age_secs.is_some() || !opts.max_age_by_kind.is_empty()
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
pub struct GcReport {
|
||
pub dead_removed: u64,
|
||
pub expired_removed: u64,
|
||
pub skipped_alive: u64,
|
||
/// Expired worktrees whose on-disk removal failed (e.g. EPERM); the
|
||
/// record stays tracked for a later retry. serde(default) so reports
|
||
/// from agents predating this field still deserialize.
|
||
#[serde(default)]
|
||
pub remove_failed: u64,
|
||
// Rebuild / stale-registration hygiene live on `AutoGcReport` (optional
|
||
// auto path), not on every `gc_worktrees` call.
|
||
}
|
||
|
||
/// `ret == 0` or non-`ESRCH` errno ⇒ alive (`EPERM`/`EACCES` included).
|
||
#[cfg(unix)]
|
||
fn pid_alive_from_kill(ret: i32, errno: i32) -> bool {
|
||
ret == 0 || errno != libc::ESRCH
|
||
}
|
||
|
||
fn is_pid_alive(pid: u32) -> bool {
|
||
#[cfg(unix)]
|
||
{
|
||
// pid 0 / >i32::MAX select process groups, not a tracked creator_pid.
|
||
if pid == 0 || pid > i32::MAX as u32 {
|
||
return false;
|
||
}
|
||
let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
|
||
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
|
||
pid_alive_from_kill(ret, errno)
|
||
}
|
||
#[cfg(not(unix))]
|
||
{
|
||
let _ = pid;
|
||
false
|
||
}
|
||
}
|
||
|
||
/// Foreign process CWDs for GC age guards.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub(crate) enum LiveCwdScan {
|
||
Ok(Vec<PathBuf>),
|
||
/// No enumerator (Windows/FreeBSD/…); PID guards only.
|
||
#[allow(dead_code)]
|
||
Unsupported,
|
||
/// Enumerator failed or unusable — age path fail-closes.
|
||
Failed,
|
||
}
|
||
|
||
/// `force` bypasses CWD requirements; `Failed` blocks age deletes.
|
||
pub(crate) fn age_path_cwd_usable(scan: &LiveCwdScan, force: bool) -> bool {
|
||
force || matches!(scan, LiveCwdScan::Ok(_) | LiveCwdScan::Unsupported)
|
||
}
|
||
|
||
fn scan_contains_cwd(cwds: &[PathBuf], path: &Path) -> bool {
|
||
let path_canon = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||
cwds.iter().any(|c| {
|
||
c.as_path() == path
|
||
|| c == &path_canon
|
||
|| dunce::canonicalize(c).is_ok_and(|cc| cc == path_canon)
|
||
})
|
||
}
|
||
|
||
/// Fail closed when our own CWD is not visible in the scan.
|
||
///
|
||
/// `current_dir()` errors also fail closed — we cannot confirm this
|
||
/// process is outside every candidate path without knowing CWD.
|
||
fn validate_cwd_scan(cwds: Vec<PathBuf>) -> LiveCwdScan {
|
||
match std::env::current_dir() {
|
||
Ok(cwd) if scan_contains_cwd(&cwds, &cwd) => LiveCwdScan::Ok(cwds),
|
||
Ok(_) => LiveCwdScan::Failed,
|
||
Err(_) => LiveCwdScan::Failed,
|
||
}
|
||
}
|
||
|
||
/// Linux `/proc/<pid>/cwd`, macOS libproc; else [`LiveCwdScan::Unsupported`].
|
||
#[cfg(target_os = "linux")]
|
||
pub(crate) fn live_process_cwds() -> LiveCwdScan {
|
||
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||
return LiveCwdScan::Failed;
|
||
};
|
||
let cwds: Vec<PathBuf> = entries
|
||
.filter_map(Result::ok)
|
||
.filter(|e| {
|
||
e.file_name()
|
||
.to_str()
|
||
.is_some_and(|n| n.parse::<u32>().is_ok())
|
||
})
|
||
.filter_map(|e| std::fs::read_link(e.path().join("cwd")).ok())
|
||
.collect();
|
||
validate_cwd_scan(cwds)
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
pub(crate) fn live_process_cwds() -> LiveCwdScan {
|
||
macos_live_process_cwds()
|
||
}
|
||
|
||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||
pub(crate) fn live_process_cwds() -> LiveCwdScan {
|
||
LiveCwdScan::Unsupported
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
const VIP_PATH_LEN: usize = 1024;
|
||
|
||
/// NUL-bounded path from fixed `vip_path` (never scan past `VIP_PATH_LEN`).
|
||
#[cfg(target_os = "macos")]
|
||
fn vip_path_to_pathbuf(path: &[[libc::c_char; 32]; 32]) -> Option<PathBuf> {
|
||
use std::ffi::OsStr;
|
||
use std::os::unix::ffi::OsStrExt;
|
||
|
||
// SAFETY: fixed-size array layout matches MAXPATHLEN vip_path.
|
||
let bytes = unsafe { std::slice::from_raw_parts(path.as_ptr().cast::<u8>(), VIP_PATH_LEN) };
|
||
let nul = bytes.iter().position(|&b| b == 0)?;
|
||
let s = &bytes[..nul];
|
||
if s.is_empty() {
|
||
return None;
|
||
}
|
||
Some(PathBuf::from(OsStr::from_bytes(s)))
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
fn macos_live_process_cwds() -> LiveCwdScan {
|
||
let Some(pids) = macos_list_all_pids() else {
|
||
return LiveCwdScan::Failed;
|
||
};
|
||
let expected = std::mem::size_of::<libc::proc_vnodepathinfo>() as i32;
|
||
let mut out = Vec::with_capacity(pids.len());
|
||
for pid in pids {
|
||
if pid <= 0 {
|
||
continue;
|
||
}
|
||
// SAFETY: zeroed buffer sized for PROC_PIDVNODEPATHINFO.
|
||
let mut info = unsafe { std::mem::zeroed::<libc::proc_vnodepathinfo>() };
|
||
let ret = unsafe {
|
||
libc::proc_pidinfo(
|
||
pid,
|
||
libc::PROC_PIDVNODEPATHINFO,
|
||
0,
|
||
(&raw mut info).cast(),
|
||
expected,
|
||
)
|
||
};
|
||
if ret != expected || info.pvi_cdir.vip_vi.vi_stat.vst_dev == 0 {
|
||
continue;
|
||
}
|
||
if let Some(p) = vip_path_to_pathbuf(&info.pvi_cdir.vip_path) {
|
||
out.push(p);
|
||
}
|
||
}
|
||
validate_cwd_scan(out)
|
||
}
|
||
|
||
/// `proc_listallpids` returns a **byte** count (probe and fill), not a PID count.
|
||
/// Convert via `size_of::<pid_t>()` / `i32` before allocating or truncating.
|
||
#[cfg(target_os = "macos")]
|
||
fn macos_list_all_pids() -> Option<Vec<i32>> {
|
||
const PID_SIZE: usize = std::mem::size_of::<i32>();
|
||
// SAFETY: null + size 0 is the documented size probe (returns bytes needed).
|
||
let bytes_needed = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
|
||
if bytes_needed < 1 {
|
||
return None;
|
||
}
|
||
let mut capacity_pids = (bytes_needed as usize) / PID_SIZE;
|
||
if capacity_pids < 1 {
|
||
return None;
|
||
}
|
||
for _ in 0..4 {
|
||
// Headroom for pids that appear between probe and fill.
|
||
capacity_pids = capacity_pids
|
||
.saturating_add(capacity_pids / 4)
|
||
.max(capacity_pids + 32);
|
||
let mut pids = vec![0i32; capacity_pids];
|
||
let buf_bytes = (pids.len() * PID_SIZE) as i32;
|
||
// SAFETY: kernel writes at most `buf_bytes` into `pids`; return is byte count.
|
||
let n_bytes = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), buf_bytes) };
|
||
if n_bytes < 1 {
|
||
return None;
|
||
}
|
||
let n_pids = (n_bytes as usize) / PID_SIZE;
|
||
if n_pids < pids.len() {
|
||
pids.truncate(n_pids);
|
||
return Some(pids);
|
||
}
|
||
// Buffer was full — grow and retry.
|
||
capacity_pids = n_pids;
|
||
}
|
||
None
|
||
}
|
||
|
||
/// True if any `live_cwds` entry sits inside `wt_path` (raw + canonical).
|
||
fn cwd_within(wt_path: &Path, live_cwds: &[PathBuf]) -> bool {
|
||
let wt_canon = dunce::canonicalize(wt_path).unwrap_or_else(|_| wt_path.to_path_buf());
|
||
live_cwds.iter().any(|cwd| {
|
||
if cwd.starts_with(wt_path) || cwd.starts_with(&wt_canon) {
|
||
return true;
|
||
}
|
||
match dunce::canonicalize(cwd) {
|
||
Ok(cwd_canon) => cwd_canon.starts_with(wt_path) || cwd_canon.starts_with(&wt_canon),
|
||
Err(_) => false,
|
||
}
|
||
})
|
||
}
|
||
|
||
fn last_active(rec: &crate::db::WorktreeRecord) -> i64 {
|
||
rec.last_accessed_at
|
||
.unwrap_or(rec.created_at)
|
||
.max(rec.created_at)
|
||
}
|
||
|
||
fn is_guarded(rec: &crate::db::WorktreeRecord, live_cwds: &[PathBuf]) -> bool {
|
||
rec.creator_pid.is_some_and(is_pid_alive) || cwd_within(Path::new(&rec.path), live_cwds)
|
||
}
|
||
|
||
fn is_path_protected(wt_path: &Path, protect_paths: &[PathBuf]) -> bool {
|
||
!protect_paths.is_empty() && cwd_within(wt_path, protect_paths)
|
||
}
|
||
|
||
/// Expired + unguarded + not path-protected + not never-expire (pre-remove re-check).
|
||
fn is_reclaimable(
|
||
rec: &crate::db::WorktreeRecord,
|
||
now: i64,
|
||
live_cwds: &[PathBuf],
|
||
opts: &GcOptions,
|
||
) -> bool {
|
||
let Some(max_age) = effective_max_age(opts, rec.kind) else {
|
||
return false;
|
||
};
|
||
let cutoff = now.saturating_sub(max_age.max(0));
|
||
if is_path_protected(Path::new(&rec.path), &opts.protect_paths) {
|
||
return false;
|
||
}
|
||
last_active(rec) < cutoff && !is_guarded(rec, live_cwds)
|
||
}
|
||
|
||
pub fn gc_worktrees(db: &WorktreeDb, opts: &GcOptions) -> Result<GcReport> {
|
||
gc_worktrees_with_delegate(db, opts, None)
|
||
}
|
||
|
||
/// Like [`gc_worktrees`], but uses `delegate` to reclaim btrfs snapshots in
|
||
/// the expired path so rootless hosts (no `CAP_SYS_ADMIN`) can delete
|
||
/// snapshots via a privileged helper instead of leaking them on EPERM.
|
||
pub fn gc_worktrees_with_delegate(
|
||
db: &WorktreeDb,
|
||
opts: &GcOptions,
|
||
delegate: Option<Arc<dyn BtrfsDelegate>>,
|
||
) -> Result<GcReport> {
|
||
let mut report = GcReport::default();
|
||
let now = crate::db::now_epoch_secs();
|
||
|
||
// Dead-record reclamation.
|
||
if opts.dry_run {
|
||
// A dry run must not mutate: skip sweep_dead (which flips records to
|
||
// dead) and only COUNT what a real run would reclaim — records that
|
||
// are already dead, or alive with a path that no longer exists (what
|
||
// sweep_dead would mark dead and then unregister).
|
||
let all = db.list(&ListFilter {
|
||
include_dead: true,
|
||
..Default::default()
|
||
})?;
|
||
report.dead_removed = all
|
||
.iter()
|
||
.filter(|r| r.status == WorktreeStatus::Dead || !Path::new(&r.path).exists())
|
||
.count() as u64;
|
||
} else {
|
||
db.sweep_dead()?;
|
||
let dead = db.list(&ListFilter {
|
||
status: Some(WorktreeStatus::Dead),
|
||
include_dead: true,
|
||
..Default::default()
|
||
})?;
|
||
for rec in dead {
|
||
// Count only when the row was actually removed.
|
||
if db.unregister(&rec.id).unwrap_or(false) {
|
||
report.dead_removed += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Expired alive-worktree reclamation (liveness-guarded).
|
||
// First-pass CWD scan (cheap filter). Pre-remove re-check rescans so a
|
||
// process that chdir'd into the tree after this snapshot is still guarded.
|
||
// Failed scan → fail closed.
|
||
if age_path_enabled(opts) {
|
||
let cwd_scan = if opts.force {
|
||
LiveCwdScan::Ok(Vec::new())
|
||
} else {
|
||
live_process_cwds()
|
||
};
|
||
if !age_path_cwd_usable(&cwd_scan, opts.force) {
|
||
tracing::warn!(
|
||
"process CWD scan failed or unusable; skipping age-expiry (fail closed)"
|
||
);
|
||
return Ok(report);
|
||
}
|
||
let live_cwds: &[PathBuf] = match &cwd_scan {
|
||
LiveCwdScan::Ok(v) => v.as_slice(),
|
||
LiveCwdScan::Unsupported => &[],
|
||
LiveCwdScan::Failed => unreachable!("gated by age_path_cwd_usable"),
|
||
};
|
||
let alive = db.list(&ListFilter::default())?;
|
||
for rec in alive {
|
||
let Some(max_age) = effective_max_age(opts, rec.kind) else {
|
||
// Metrics-only: never-expire kinds are retained on purpose.
|
||
// Prefer global `max_age_secs` as the "would have expired"
|
||
// reference when set; when only per-kind ages apply
|
||
// (`max_age_secs = None`), still count so dry-run/dogfood
|
||
// skip totals are not under-counted.
|
||
if let Some(ref_age) = opts.max_age_secs {
|
||
let ref_cutoff = now.saturating_sub(ref_age.max(0));
|
||
if last_active(&rec) < ref_cutoff {
|
||
report.skipped_alive += 1;
|
||
}
|
||
} else {
|
||
report.skipped_alive += 1;
|
||
}
|
||
continue;
|
||
};
|
||
let cutoff = now.saturating_sub(max_age.max(0));
|
||
if last_active(&rec) >= cutoff {
|
||
continue;
|
||
}
|
||
let path = Path::new(&rec.path);
|
||
if !opts.force
|
||
&& (is_guarded(&rec, live_cwds) || is_path_protected(path, &opts.protect_paths))
|
||
{
|
||
report.skipped_alive += 1;
|
||
continue;
|
||
}
|
||
if opts.dry_run {
|
||
if path.exists() {
|
||
report.expired_removed += 1;
|
||
}
|
||
continue;
|
||
}
|
||
// Fresh DB row + fresh CWD scan immediately before remove.
|
||
if !opts.force {
|
||
match db.get_by_id(&rec.id) {
|
||
Ok(Some(fresh)) => {
|
||
let recheck_scan = live_process_cwds();
|
||
if !age_path_cwd_usable(&recheck_scan, false) {
|
||
tracing::warn!(
|
||
"process CWD scan failed on pre-remove re-check; skipping remaining age-expiry (fail closed)"
|
||
);
|
||
return Ok(report);
|
||
}
|
||
let recheck_cwds: &[PathBuf] = match &recheck_scan {
|
||
LiveCwdScan::Ok(v) => v.as_slice(),
|
||
LiveCwdScan::Unsupported => &[],
|
||
LiveCwdScan::Failed => {
|
||
unreachable!("gated by age_path_cwd_usable")
|
||
}
|
||
};
|
||
if !is_reclaimable(&fresh, now, recheck_cwds, opts) {
|
||
report.skipped_alive += 1;
|
||
continue;
|
||
}
|
||
}
|
||
Ok(None) | Err(_) => continue,
|
||
}
|
||
}
|
||
if path.exists() {
|
||
match super::remove_worktree_with_delegate(path, delegate.clone()) {
|
||
Ok(_) => report.expired_removed += 1,
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
path = %path.display(),
|
||
error = %e,
|
||
"failed to remove expired worktree"
|
||
);
|
||
report.remove_failed += 1;
|
||
}
|
||
}
|
||
} else if db.unregister(&rec.id).unwrap_or(false) {
|
||
report.expired_removed += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(report)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn pid_alive_from_kill_decodes_errno() {
|
||
// Testable without needing a process in each errno state.
|
||
assert!(pid_alive_from_kill(0, 0), "ret==0 ⇒ alive");
|
||
assert!(!pid_alive_from_kill(-1, libc::ESRCH), "ESRCH ⇒ dead");
|
||
assert!(
|
||
pid_alive_from_kill(-1, libc::EPERM),
|
||
"EPERM ⇒ alive (owned by another user)"
|
||
);
|
||
assert!(pid_alive_from_kill(-1, libc::EACCES), "EACCES ⇒ alive");
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn is_pid_alive_true_for_running_processes() {
|
||
assert!(is_pid_alive(std::process::id()));
|
||
// PID 1 (init/launchd) always exists on Unix.
|
||
assert!(is_pid_alive(1), "pid 1 must be detected as alive");
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
#[test]
|
||
fn is_pid_alive_never_false_alive_on_non_unix() {
|
||
// Safe fallback: never report a pid as alive without a real probe.
|
||
assert!(!is_pid_alive(std::process::id()));
|
||
assert!(!is_pid_alive(0));
|
||
assert!(!is_pid_alive(u32::MAX));
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn is_pid_alive_false_for_guarded_pids() {
|
||
// pid 0 and pid > i32::MAX are process-group selectors to kill(2), not
|
||
// real tracked pids; the guard short-circuits them to dead.
|
||
assert!(!is_pid_alive(0));
|
||
assert!(!is_pid_alive(u32::MAX));
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn is_pid_alive_false_for_reaped_child() {
|
||
// A fully reaped child's pid is gone (ESRCH) and must read as dead.
|
||
#[allow(clippy::disallowed_methods)] // test fixture; the test reaps it
|
||
let mut child = std::process::Command::new("true")
|
||
.spawn()
|
||
.expect("spawn `true`");
|
||
let pid = child.id();
|
||
child.wait().expect("wait on `true`");
|
||
assert!(!is_pid_alive(pid));
|
||
}
|
||
|
||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||
#[test]
|
||
fn live_process_cwds_includes_own_cwd_after_chdir() {
|
||
let _cwd_lock = crate::api::cwd_test_guard();
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let dir = dunce::canonicalize(tmp.path()).unwrap();
|
||
let _cwd = crate::api::CwdGuard(std::env::current_dir().unwrap());
|
||
std::env::set_current_dir(&dir).expect("chdir into temp");
|
||
let LiveCwdScan::Ok(cwds) = live_process_cwds() else {
|
||
panic!("CWD scan must succeed on this OS after chdir");
|
||
};
|
||
assert!(
|
||
scan_contains_cwd(&cwds, &dir),
|
||
"own CWD {dir:?} must appear in live_process_cwds (got {} entries)",
|
||
cwds.len()
|
||
);
|
||
}
|
||
|
||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||
#[test]
|
||
fn live_process_cwds_ok_and_nonempty_on_supported_os() {
|
||
let _cwd_lock = crate::api::cwd_test_guard();
|
||
match live_process_cwds() {
|
||
LiveCwdScan::Ok(cwds) => assert!(
|
||
!cwds.is_empty(),
|
||
"process CWD scan must observe at least one CWD on this OS"
|
||
),
|
||
other => panic!("expected LiveCwdScan::Ok on supported OS, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn validate_cwd_scan_fails_closed_when_self_missing() {
|
||
let _cwd_lock = crate::api::cwd_test_guard();
|
||
assert!(
|
||
matches!(validate_cwd_scan(Vec::new()), LiveCwdScan::Failed),
|
||
"empty scan cannot observe self CWD"
|
||
);
|
||
assert!(
|
||
matches!(
|
||
validate_cwd_scan(vec![PathBuf::from("/no/such/unrelated/cwd")]),
|
||
LiveCwdScan::Failed
|
||
),
|
||
"unrelated paths only ⇒ unusable scan"
|
||
);
|
||
let cwd = std::env::current_dir().unwrap();
|
||
match validate_cwd_scan(vec![cwd.clone()]) {
|
||
LiveCwdScan::Ok(v) => {
|
||
assert_eq!(v.len(), 1);
|
||
assert_eq!(v[0], cwd);
|
||
}
|
||
other => panic!("self path must validate: {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn age_path_cwd_usable_fail_closed_on_failed_scan() {
|
||
assert!(
|
||
!age_path_cwd_usable(&LiveCwdScan::Failed, false),
|
||
"Failed scan must block age path"
|
||
);
|
||
assert!(
|
||
age_path_cwd_usable(&LiveCwdScan::Failed, true),
|
||
"force bypasses CWD scan requirement"
|
||
);
|
||
assert!(age_path_cwd_usable(
|
||
&LiveCwdScan::Ok(vec![PathBuf::from("/")]),
|
||
false
|
||
));
|
||
assert!(age_path_cwd_usable(&LiveCwdScan::Unsupported, false));
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
#[test]
|
||
fn vip_path_to_pathbuf_respects_nul_bound() {
|
||
let mut raw = [[0 as libc::c_char; 32]; 32];
|
||
// "ab" then NUL — rest garbage must not be read.
|
||
raw[0][0] = b'a' as libc::c_char;
|
||
raw[0][1] = b'b' as libc::c_char;
|
||
raw[0][2] = 0;
|
||
raw[0][3] = b'x' as libc::c_char;
|
||
let p = vip_path_to_pathbuf(&raw).expect("path");
|
||
assert_eq!(p, PathBuf::from("ab"));
|
||
// All zeros → None
|
||
assert!(vip_path_to_pathbuf(&[[0 as libc::c_char; 32]; 32]).is_none());
|
||
// No NUL within 1024 → None (not UB)
|
||
let no_nul = [[b'z' as libc::c_char; 32]; 32];
|
||
assert!(vip_path_to_pathbuf(&no_nul).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn cwd_within_matches_nested_and_canonical_paths() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let wt = tmp.path().join("wt");
|
||
std::fs::create_dir_all(wt.join("a").join("b")).unwrap();
|
||
// A CWD nested in the tree counts as live; a sibling dir does not.
|
||
assert!(cwd_within(&wt, &[wt.join("a").join("b")]));
|
||
assert!(!cwd_within(&wt, &[tmp.path().join("other")]));
|
||
assert!(!cwd_within(&wt, &[]));
|
||
// protect/cwd entry matching after canonicalize (raw path may differ).
|
||
let nested = wt.join("a").join("b");
|
||
let nested_canon = dunce::canonicalize(&nested).unwrap();
|
||
let wt_canon = dunce::canonicalize(&wt).unwrap();
|
||
assert!(cwd_within(&wt_canon, &[nested]));
|
||
assert!(cwd_within(&wt, &[nested_canon]));
|
||
}
|
||
|
||
fn rec_at(path: &str, created_at: i64) -> crate::db::WorktreeRecord {
|
||
crate::db::WorktreeRecord {
|
||
id: "r".to_string(),
|
||
path: path.into(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: crate::db::WorktreeKind::Session,
|
||
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,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn is_reclaimable_requires_expired_and_unguarded() {
|
||
let now = 1_000;
|
||
// max_age=0 → cutoff=now; created_at=1 is expired.
|
||
let base = GcOptions {
|
||
max_age_secs: Some(0),
|
||
..Default::default()
|
||
};
|
||
assert!(is_reclaimable(&rec_at("/no/such/wt", 1), now, &[], &base));
|
||
// A recent last_accessed_at within the window protects it.
|
||
let mut fresh = rec_at("/no/such/wt", 1);
|
||
fresh.last_accessed_at = Some(now + 10);
|
||
assert!(!is_reclaimable(&fresh, now, &[], &base));
|
||
// A live creator pid protects it (Unix probe only; non-Unix is never alive).
|
||
let mut live_creator = rec_at("/no/such/wt", 1);
|
||
live_creator.creator_pid = Some(std::process::id());
|
||
#[cfg(unix)]
|
||
assert!(!is_reclaimable(&live_creator, now, &[], &base));
|
||
#[cfg(not(unix))]
|
||
assert!(
|
||
is_reclaimable(&live_creator, now, &[], &base),
|
||
"non-Unix PID probe is fail-closed (never alive)"
|
||
);
|
||
// A live process CWD inside the tree protects it.
|
||
let inside = std::path::PathBuf::from("/no/such/wt/sub");
|
||
assert!(!is_reclaimable(
|
||
&rec_at("/no/such/wt", 1),
|
||
now,
|
||
&[inside],
|
||
&base
|
||
));
|
||
// protect_paths hit (same semantics as CWD) blocks reclaim.
|
||
let protect = std::path::PathBuf::from("/no/such/wt/nested");
|
||
let with_protect = GcOptions {
|
||
max_age_secs: Some(0),
|
||
protect_paths: vec![protect],
|
||
..Default::default()
|
||
};
|
||
assert!(!is_reclaimable(
|
||
&rec_at("/no/such/wt", 1),
|
||
now,
|
||
&[],
|
||
&with_protect
|
||
));
|
||
// skip_kinds / never-expire blocks reclaim even when otherwise expired.
|
||
let skip = GcOptions {
|
||
max_age_secs: Some(0),
|
||
skip_kinds: vec![WorktreeKind::Session],
|
||
..Default::default()
|
||
};
|
||
assert!(!is_reclaimable(&rec_at("/no/such/wt", 1), now, &[], &skip));
|
||
let never_map = GcOptions {
|
||
max_age_secs: Some(0),
|
||
max_age_by_kind: [(WorktreeKind::Session, None)].into_iter().collect(),
|
||
..Default::default()
|
||
};
|
||
assert!(!is_reclaimable(
|
||
&rec_at("/no/such/wt", 1),
|
||
now,
|
||
&[],
|
||
&never_map
|
||
));
|
||
// Finite per-kind cutoff: age=100 with max=50 → reclaimable; max=200 → not.
|
||
let mut aged = rec_at("/no/such/wt", 1);
|
||
aged.last_accessed_at = Some(now - 100);
|
||
let short = GcOptions {
|
||
max_age_secs: Some(10_000),
|
||
max_age_by_kind: [(WorktreeKind::Session, Some(50))].into_iter().collect(),
|
||
..Default::default()
|
||
};
|
||
assert!(is_reclaimable(&aged, now, &[], &short));
|
||
let long = GcOptions {
|
||
max_age_secs: Some(10),
|
||
max_age_by_kind: [(WorktreeKind::Session, Some(200))].into_iter().collect(),
|
||
..Default::default()
|
||
};
|
||
assert!(!is_reclaimable(&aged, now, &[], &long));
|
||
}
|
||
|
||
#[test]
|
||
fn effective_max_age_precedence() {
|
||
let opts = GcOptions {
|
||
max_age_secs: Some(100),
|
||
skip_kinds: vec![WorktreeKind::Manual],
|
||
max_age_by_kind: [
|
||
(WorktreeKind::Subagent, Some(10)),
|
||
(WorktreeKind::Pool, None),
|
||
// Conflict: map says expire Manual, skip_kinds wins.
|
||
(WorktreeKind::Manual, Some(1)),
|
||
]
|
||
.into_iter()
|
||
.collect(),
|
||
..Default::default()
|
||
};
|
||
assert_eq!(effective_max_age(&opts, WorktreeKind::Session), Some(100));
|
||
assert_eq!(effective_max_age(&opts, WorktreeKind::Subagent), Some(10));
|
||
assert_eq!(effective_max_age(&opts, WorktreeKind::Pool), None);
|
||
assert_eq!(
|
||
effective_max_age(&opts, WorktreeKind::Manual),
|
||
None,
|
||
"skip_kinds beats max_age_by_kind for the same kind"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn is_out_of_disk_detects_storage_full_kind() {
|
||
// Cross-platform: std maps ENOSPC and the Windows disk-full codes onto
|
||
// ErrorKind::StorageFull, so the typed check fires on every OS.
|
||
let io = std::io::Error::from(std::io::ErrorKind::StorageFull);
|
||
let err = anyhow::Error::new(io).context("failed to copy index from a to b");
|
||
assert!(is_out_of_disk(&err));
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn is_out_of_disk_detects_enospc_io_error() {
|
||
// Real ENOSPC (errno 28 on Linux/macOS) must decode to StorageFull.
|
||
let io = std::io::Error::from_raw_os_error(28);
|
||
assert_eq!(io.kind(), std::io::ErrorKind::StorageFull);
|
||
let err = anyhow::Error::new(io).context("failed to copy index from a to b");
|
||
assert!(is_out_of_disk(&err));
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn is_out_of_disk_detects_windows_disk_full_codes() {
|
||
// Windows reports a full disk as ERROR_DISK_FULL (112) or
|
||
// ERROR_HANDLE_DISK_FULL (39); std decodes both to StorageFull.
|
||
for code in [112, 39] {
|
||
let io = std::io::Error::from_raw_os_error(code);
|
||
assert_eq!(io.kind(), std::io::ErrorKind::StorageFull);
|
||
let err = anyhow::Error::new(io).context("failed to copy index from a to b");
|
||
assert!(is_out_of_disk(&err));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn is_out_of_disk_detects_message_text() {
|
||
// `git` subcommands surface ENOSPC only as stderr text.
|
||
let err = anyhow::anyhow!("git worktree add failed: No space left on device");
|
||
assert!(is_out_of_disk(&err));
|
||
}
|
||
|
||
#[test]
|
||
fn is_out_of_disk_ignores_unrelated_errors() {
|
||
let err = anyhow::anyhow!("failed to get HEAD commit from source");
|
||
assert!(!is_out_of_disk(&err));
|
||
}
|
||
|
||
#[test]
|
||
fn annotate_disk_full_promotes_reason_to_top_context() {
|
||
let err = anyhow::anyhow!("failed to copy index: No space left on device (os error 28)");
|
||
let annotated = annotate_disk_full(err);
|
||
// Display (top context only) now carries the disk reason, so it
|
||
// survives the workspace/ACP flattening to a single message.
|
||
assert_eq!(annotated.to_string(), OUT_OF_DISK_CONTEXT);
|
||
// The original chain is preserved underneath for logs.
|
||
assert!(format!("{annotated:#}").contains("failed to copy index"));
|
||
}
|
||
|
||
#[test]
|
||
fn annotate_disk_full_leaves_other_errors_unchanged() {
|
||
let err = anyhow::anyhow!("some other failure");
|
||
assert_eq!(annotate_disk_full(err).to_string(), "some other failure");
|
||
}
|
||
|
||
#[test]
|
||
fn test_copy_report_from_copy_stats() {
|
||
let stats = CopyStats {
|
||
files_copied: 10,
|
||
dirs_created: 3,
|
||
symlinks_copied: 2,
|
||
files_skipped: 5,
|
||
issues: vec!["warning 1".to_string(), "warning 2".to_string()],
|
||
};
|
||
|
||
let report: CopyReport = stats.into();
|
||
assert_eq!(report.files_copied, 10);
|
||
assert_eq!(report.dirs_created, 3);
|
||
assert_eq!(report.symlinks_copied, 2);
|
||
assert_eq!(report.files_skipped, 5);
|
||
assert_eq!(report.issues.len(), 2);
|
||
assert!(report.dirty_files.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_btrfs_mode_default() {
|
||
let mode = BtrfsMode::default();
|
||
assert_eq!(mode, BtrfsMode::Auto);
|
||
}
|
||
|
||
#[test]
|
||
fn test_btrfs_mode_variants() {
|
||
// Test that all variants can be created and compared
|
||
assert_eq!(BtrfsMode::Auto, BtrfsMode::Auto);
|
||
assert_eq!(BtrfsMode::Force, BtrfsMode::Force);
|
||
assert_eq!(BtrfsMode::Disabled, BtrfsMode::Disabled);
|
||
|
||
assert_ne!(BtrfsMode::Auto, BtrfsMode::Force);
|
||
assert_ne!(BtrfsMode::Auto, BtrfsMode::Disabled);
|
||
assert_ne!(BtrfsMode::Force, BtrfsMode::Disabled);
|
||
}
|
||
|
||
#[test]
|
||
fn test_btrfs_mode_debug() {
|
||
// Test that Debug is implemented
|
||
let auto = format!("{:?}", BtrfsMode::Auto);
|
||
let force = format!("{:?}", BtrfsMode::Force);
|
||
let disabled = format!("{:?}", BtrfsMode::Disabled);
|
||
|
||
assert!(auto.contains("Auto"));
|
||
assert!(force.contains("Force"));
|
||
assert!(disabled.contains("Disabled"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_btrfs_mode_clone() {
|
||
let mode = BtrfsMode::Force;
|
||
let cloned = mode.clone();
|
||
assert_eq!(mode, cloned);
|
||
}
|
||
|
||
#[test]
|
||
fn test_creation_mode_default() {
|
||
let mode = CreationMode::default();
|
||
assert_eq!(mode, CreationMode::Linked);
|
||
}
|
||
|
||
#[test]
|
||
fn test_creation_mode_variants() {
|
||
assert_eq!(CreationMode::Linked, CreationMode::Linked);
|
||
assert_eq!(CreationMode::Standalone, CreationMode::Standalone);
|
||
assert_eq!(CreationMode::GitCheckout, CreationMode::GitCheckout);
|
||
assert_ne!(CreationMode::Linked, CreationMode::Standalone);
|
||
assert_ne!(CreationMode::Linked, CreationMode::GitCheckout);
|
||
}
|
||
|
||
#[test]
|
||
fn test_worktree_builder_chain() {
|
||
// Test that all builder methods can be chained
|
||
let _builder = WorktreeBuilder::new("/source", "/dest")
|
||
.git_ref("main")
|
||
.parallelism(4)
|
||
.ignored_parallelism(2)
|
||
.channel_buffer(512)
|
||
.working_tree_mode(WorkingTreeMode::CleanAll)
|
||
.ignored_files_mode(IgnoredFilesMode::Copy {
|
||
skip_patterns: vec!["*.log".to_string()],
|
||
})
|
||
.creation_mode(CreationMode::GitCheckout);
|
||
}
|
||
|
||
#[test]
|
||
fn test_standalone_shorthand() {
|
||
// .standalone(true) should be equivalent to .creation_mode(Standalone)
|
||
let _builder = WorktreeBuilder::new("/source", "/dest").standalone(true);
|
||
}
|
||
|
||
#[test]
|
||
fn copy_ignored_only_returns_err_when_cancelled() {
|
||
let src = tempfile::TempDir::new().unwrap();
|
||
let dest = tempfile::TempDir::new().unwrap();
|
||
std::fs::write(src.path().join("file.txt"), "content").unwrap();
|
||
|
||
let token = CancellationToken::new();
|
||
token.cancel();
|
||
|
||
let err = WorktreeBuilder::new(src.path(), dest.path())
|
||
.cancellation_token(token)
|
||
.copy_ignored_only()
|
||
.expect_err("a pre-cancelled token must produce an error, not Ok(partial)");
|
||
|
||
assert!(
|
||
err.to_string().contains("cancelled"),
|
||
"error should report cancellation, got: {err}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_cleanup_report_default() {
|
||
let report = CleanupReport::default();
|
||
assert_eq!(report.removed, 0);
|
||
assert_eq!(report.overlays_unmounted, 0);
|
||
assert_eq!(report.btrfs_deleted, 0);
|
||
assert_eq!(report.errors, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_cleanup_worktrees_in_empty_dir() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let report = cleanup_worktrees_in(tmp.path());
|
||
assert_eq!(report.removed, 0);
|
||
assert_eq!(report.errors, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_cleanup_worktrees_in_missing_dir() {
|
||
let report = cleanup_worktrees_in(std::path::Path::new("/nonexistent/path/xyz"));
|
||
assert_eq!(report.removed, 0);
|
||
assert_eq!(report.errors, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_cleanup_worktrees_in_with_plain_worktrees() {
|
||
xai_test_utils::require_git!();
|
||
use xai_test_utils::git::{git_commit_all, init_git_repo};
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
|
||
// Create a source repo.
|
||
let repo_path = tmp.path().join("repo");
|
||
std::fs::create_dir(&repo_path).unwrap();
|
||
init_git_repo(&repo_path);
|
||
std::fs::write(repo_path.join("file.txt"), "content").unwrap();
|
||
git_commit_all(&repo_path, "initial");
|
||
|
||
// Create two worktrees in a worktrees dir.
|
||
let worktrees_dir = tmp.path().join("worktrees");
|
||
std::fs::create_dir(&worktrees_dir).unwrap();
|
||
|
||
let wt1 = worktrees_dir.join("wt1");
|
||
let wt2 = worktrees_dir.join("wt2");
|
||
|
||
WorktreeBuilder::new(&repo_path, &wt1).create().unwrap();
|
||
WorktreeBuilder::new(&repo_path, &wt2).create().unwrap();
|
||
|
||
assert!(wt1.exists());
|
||
assert!(wt2.exists());
|
||
|
||
// Cleanup should remove both.
|
||
let report = cleanup_worktrees_in(&worktrees_dir);
|
||
assert_eq!(report.removed, 2);
|
||
assert_eq!(report.errors, 0);
|
||
assert!(!wt1.exists());
|
||
assert!(!wt2.exists());
|
||
}
|
||
|
||
#[test]
|
||
fn test_cleanup_worktrees_in_with_nested_dirs() {
|
||
xai_test_utils::require_git!();
|
||
use xai_test_utils::git::{git_commit_all, init_git_repo};
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
|
||
// Create a source repo.
|
||
let repo_path = tmp.path().join("repo");
|
||
std::fs::create_dir(&repo_path).unwrap();
|
||
init_git_repo(&repo_path);
|
||
std::fs::write(repo_path.join("file.txt"), "content").unwrap();
|
||
git_commit_all(&repo_path, "initial");
|
||
|
||
// Create ~/.grok/worktrees/<repo>/<session>/ structure.
|
||
let worktrees_dir = tmp.path().join("worktrees");
|
||
let repo_group = worktrees_dir.join("myrepo");
|
||
std::fs::create_dir_all(&repo_group).unwrap();
|
||
|
||
let wt1 = repo_group.join("session-1");
|
||
WorktreeBuilder::new(&repo_path, &wt1).create().unwrap();
|
||
assert!(wt1.exists());
|
||
|
||
// Cleanup should find the nested worktree.
|
||
let report = cleanup_worktrees_in(&worktrees_dir);
|
||
assert_eq!(report.removed, 1);
|
||
assert_eq!(report.errors, 0);
|
||
assert!(!wt1.exists());
|
||
// Grouping dir should be removed since it's empty now.
|
||
assert!(!repo_group.exists());
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn test_cleanup_worktrees_in_removes_dangling_symlink() {
|
||
// A worktree exposed as a symlink whose snapshot was already deleted is a
|
||
// dangling symlink; it must be unlinked, not skipped (`is_dir()` follows
|
||
// the link and returns false, which would leak it).
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let worktrees_dir = tmp.path().join("worktrees");
|
||
std::fs::create_dir(&worktrees_dir).unwrap();
|
||
|
||
let dangling = worktrees_dir.join("dead-wt");
|
||
std::os::unix::fs::symlink(tmp.path().join("gone-snapshot"), &dangling).unwrap();
|
||
assert!(dangling.symlink_metadata().is_ok());
|
||
assert!(!dangling.is_dir(), "precondition: dangling symlink");
|
||
|
||
let report = cleanup_worktrees_in(&worktrees_dir);
|
||
|
||
assert!(
|
||
dangling.symlink_metadata().is_err(),
|
||
"dangling symlink worktree must be removed, not skipped"
|
||
);
|
||
assert_eq!(report.removed, 1);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn test_cleanup_worktrees_in_removes_nested_dangling_symlink() {
|
||
// Dangling symlink one level deeper (~/.grok/worktrees/<repo>/<session>):
|
||
// the nested branch must also unlink it rather than skip it.
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let worktrees_dir = tmp.path().join("worktrees");
|
||
// A grouping dir with NO `.git`, so cleanup recurses into it.
|
||
let repo_group = worktrees_dir.join("myrepo");
|
||
std::fs::create_dir_all(&repo_group).unwrap();
|
||
|
||
let dangling = repo_group.join("dead-session");
|
||
std::os::unix::fs::symlink(tmp.path().join("gone-snapshot"), &dangling).unwrap();
|
||
assert!(!dangling.is_dir(), "precondition: dangling symlink");
|
||
|
||
let report = cleanup_worktrees_in(&worktrees_dir);
|
||
|
||
assert!(
|
||
dangling.symlink_metadata().is_err(),
|
||
"nested dangling symlink worktree must be removed"
|
||
);
|
||
assert_eq!(report.removed, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_remove_report_has_overlay_field() {
|
||
let report = RemoveReport {
|
||
used_btrfs_delete: false,
|
||
unmounted_bind: false,
|
||
unmounted_overlay: true,
|
||
};
|
||
assert!(report.unmounted_overlay);
|
||
assert!(!report.used_btrfs_delete);
|
||
}
|
||
|
||
#[test]
|
||
fn test_creation_mode_as_db_str() {
|
||
assert_eq!(CreationMode::Linked.as_db_str(), "linked");
|
||
assert_eq!(CreationMode::Standalone.as_db_str(), "standalone");
|
||
assert_eq!(CreationMode::GitCheckout.as_db_str(), "git");
|
||
}
|
||
|
||
#[test]
|
||
fn test_remove_worktree_with_delegate_no_delegate() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let path = tmp.path().join("nonexistent");
|
||
let result = remove_worktree_with_delegate(&path, None);
|
||
assert!(result.is_ok());
|
||
let report = result.unwrap();
|
||
assert!(!report.used_btrfs_delete);
|
||
assert!(!report.unmounted_bind);
|
||
assert!(!report.unmounted_overlay);
|
||
}
|
||
|
||
#[test]
|
||
fn test_remove_worktree_with_delegate_existing_dir() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let path = tmp.path().join("some-dir");
|
||
std::fs::create_dir(&path).unwrap();
|
||
let result = remove_worktree_with_delegate(&path, None);
|
||
assert!(result.is_ok());
|
||
assert!(!path.exists());
|
||
}
|
||
|
||
/// A plain (non-snapshot) linked worktree removed through the delegate-aware
|
||
/// path must still deregister `.git/worktrees/<name>`, and the delegate must
|
||
/// be used only as a fallback — never invoked when the direct removal succeeds.
|
||
#[test]
|
||
fn remove_with_delegate_deregisters_plain_worktree_without_calling_delegate() {
|
||
xai_test_utils::require_git!();
|
||
use xai_test_utils::git::{git_commit_all, init_git_repo};
|
||
// Isolate GROK_HOME so the post-removal unregister writes to a private DB.
|
||
#[cfg(feature = "metadata")]
|
||
let _fx = crate::db::GrokHomeFixture::new();
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let repo = tmp.path().join("repo");
|
||
std::fs::create_dir(&repo).unwrap();
|
||
init_git_repo(&repo);
|
||
std::fs::write(repo.join("file.txt"), "content").unwrap();
|
||
git_commit_all(&repo, "initial");
|
||
|
||
let wt = tmp.path().join("worktrees").join("wt1");
|
||
WorktreeBuilder::new(&repo, &wt).create().unwrap();
|
||
|
||
// `.git` is a file pointing at `<repo>/.git/worktrees/<name>`.
|
||
let registration_dir =
|
||
read_worktree_gitdir(&wt).expect("linked worktree must have a gitdir pointer");
|
||
assert!(
|
||
registration_dir.exists(),
|
||
"precondition: registration exists"
|
||
);
|
||
|
||
let deletes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||
let delegate: Arc<dyn BtrfsDelegate> = Arc::new(RecordingDelegate {
|
||
snapshot_path: PathBuf::from("/unused"),
|
||
worktree_path: PathBuf::from("/unused"),
|
||
deletes: deletes.clone(),
|
||
});
|
||
|
||
let report = remove_worktree_with_delegate(&wt, Some(delegate)).unwrap();
|
||
|
||
assert!(!wt.exists(), "worktree directory must be removed");
|
||
assert!(
|
||
!registration_dir.exists(),
|
||
"`.git/worktrees/<name>` registration must be deregistered"
|
||
);
|
||
assert!(!report.used_btrfs_delete);
|
||
assert_eq!(
|
||
deletes.load(std::sync::atomic::Ordering::Relaxed),
|
||
0,
|
||
"delegate is a fallback only; a plain worktree removal must not call it"
|
||
);
|
||
}
|
||
|
||
/// When the direct `btrfs delete` fails (e.g. EPERM on a rootless host),
|
||
/// the snapshot delete must fall back to the delegate and return its report.
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn delete_fallback_invokes_delegate_when_direct_delete_fails() {
|
||
let deletes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||
let delegate: Arc<dyn BtrfsDelegate> = Arc::new(RecordingDelegate {
|
||
snapshot_path: PathBuf::from("/unused"),
|
||
worktree_path: PathBuf::from("/unused"),
|
||
deletes: deletes.clone(),
|
||
});
|
||
|
||
let report = delete_snapshot_with_delegate_fallback(
|
||
Path::new("/mnt/btrfs/worktrees/snap-1"),
|
||
Path::new("/home/u/.grok/worktrees/repo/wt"),
|
||
Some(&delegate),
|
||
|_| anyhow::bail!("operation not permitted (os error 1)"),
|
||
)
|
||
.unwrap()
|
||
.expect("delegate fallback must handle the failed direct delete");
|
||
assert!(report.used_btrfs_delete);
|
||
assert_eq!(deletes.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||
}
|
||
|
||
/// A successful direct delete returns `None` (caller does local cleanup) and
|
||
/// never touches the delegate.
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn delete_fallback_skips_delegate_when_direct_delete_succeeds() {
|
||
let deletes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||
let delegate: Arc<dyn BtrfsDelegate> = Arc::new(RecordingDelegate {
|
||
snapshot_path: PathBuf::from("/unused"),
|
||
worktree_path: PathBuf::from("/unused"),
|
||
deletes: deletes.clone(),
|
||
});
|
||
|
||
let res = delete_snapshot_with_delegate_fallback(
|
||
Path::new("/snap"),
|
||
Path::new("/wt"),
|
||
Some(&delegate),
|
||
|_| Ok(()),
|
||
)
|
||
.unwrap();
|
||
assert!(res.is_none(), "successful direct delete must return None");
|
||
assert_eq!(deletes.load(std::sync::atomic::Ordering::Relaxed), 0);
|
||
}
|
||
|
||
/// With no delegate, a failed direct delete propagates the original error so
|
||
/// the worktree reference is preserved for a retry.
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn delete_fallback_without_delegate_propagates_error() {
|
||
let err = delete_snapshot_with_delegate_fallback(
|
||
Path::new("/snap"),
|
||
Path::new("/wt"),
|
||
None,
|
||
|_| anyhow::bail!("EPERM marker"),
|
||
)
|
||
.unwrap_err();
|
||
assert!(err.to_string().contains("EPERM marker"));
|
||
}
|
||
|
||
/// Metadata persisted by the public `write_btrfs_metadata` lets metadata-based
|
||
/// removal locate a worktree purely from its `mount_target` and drop the
|
||
/// symlink + metadata, even when the snapshot subvolume is already gone.
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn metadata_written_by_public_writer_is_found_by_metadata_removal() {
|
||
use crate::btrfs;
|
||
use crate::mount_info::MountEntry;
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let mount = tmp.path();
|
||
let worktrees_dir = mount.join("worktrees");
|
||
std::fs::create_dir(&worktrees_dir).unwrap();
|
||
|
||
// `dest` is a symlink to a snapshot under <mount>/worktrees/, with metadata.
|
||
let snapshot_path = worktrees_dir.join("snap-1");
|
||
let dest = tmp.path().join("dest-worktree");
|
||
std::os::unix::fs::symlink(&snapshot_path, &dest).unwrap();
|
||
|
||
btrfs::write_btrfs_metadata(&snapshot_path, &dest).unwrap();
|
||
let meta_path = btrfs::btrfs_meta_path(&snapshot_path).unwrap();
|
||
assert!(
|
||
meta_path.exists(),
|
||
"metadata must be written next to snapshot"
|
||
);
|
||
assert!(
|
||
dest.symlink_metadata().is_ok(),
|
||
"precondition: symlink exists"
|
||
);
|
||
|
||
let entries = vec![MountEntry {
|
||
mount_id: 1,
|
||
parent_id: 0,
|
||
root: "/".to_string(),
|
||
mount_point: mount.to_path_buf(),
|
||
fs_type: "btrfs".to_string(),
|
||
source: "/dev/loop0".to_string(),
|
||
super_options: String::new(),
|
||
}];
|
||
|
||
let report = try_btrfs_remove_from_metadata_inner(&dest, &entries, None)
|
||
.unwrap()
|
||
.expect("metadata removal must find the snapshot by mount_target");
|
||
// Snapshot subvolume is already gone, so no btrfs delete is attempted.
|
||
assert!(!report.used_btrfs_delete);
|
||
assert!(
|
||
dest.symlink_metadata().is_err(),
|
||
"the worktree symlink must be removed"
|
||
);
|
||
assert!(
|
||
!meta_path.exists(),
|
||
"metadata must be cleaned up after handling"
|
||
);
|
||
}
|
||
|
||
/// Metadata written by the public `write_btrfs_metadata` for a snapshot whose
|
||
/// worktree is gone (orphan) must be discovered and reclaimed by the orphan
|
||
/// scanner.
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn metadata_written_by_public_writer_is_reclaimed_by_orphan_scan() {
|
||
use crate::btrfs;
|
||
use crate::mount_info::MountEntry;
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let mount = tmp.path();
|
||
let worktrees_dir = mount.join("worktrees");
|
||
std::fs::create_dir(&worktrees_dir).unwrap();
|
||
|
||
// Orphan: the worktree's mount_target no longer exists (symlink lost),
|
||
// so the scanner must treat it as reclaimable rather than active.
|
||
let snapshot_path = worktrees_dir.join("snap-orphan");
|
||
let mount_target = tmp.path().join("gone-dest");
|
||
btrfs::write_btrfs_metadata(&snapshot_path, &mount_target).unwrap();
|
||
let meta_path = btrfs::btrfs_meta_path(&snapshot_path).unwrap();
|
||
assert!(meta_path.exists());
|
||
|
||
let entries = vec![MountEntry {
|
||
mount_id: 1,
|
||
parent_id: 0,
|
||
root: "/".to_string(),
|
||
mount_point: mount.to_path_buf(),
|
||
fs_type: "btrfs".to_string(),
|
||
source: "/dev/loop0".to_string(),
|
||
super_options: String::new(),
|
||
}];
|
||
|
||
let report = cleanup_orphaned_btrfs_snapshots_inner(&entries);
|
||
assert_eq!(report.removed, 1, "orphaned snapshot must be reclaimed");
|
||
// Snapshot subvolume doesn't exist on disk, so no btrfs delete is attempted.
|
||
assert_eq!(report.btrfs_deleted, 0);
|
||
assert!(!meta_path.exists(), "orphan metadata must be cleaned up");
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn test_cleanup_orphaned_btrfs_snapshots_no_mounts() {
|
||
let report = cleanup_orphaned_btrfs_snapshots_inner(&[]);
|
||
assert_eq!(report.removed, 0);
|
||
assert_eq!(report.errors, 0);
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn test_cleanup_orphaned_btrfs_snapshots_with_metadata() {
|
||
use crate::btrfs;
|
||
use crate::mount_info::MountEntry;
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let worktrees_dir = tmp.path().join("worktrees");
|
||
std::fs::create_dir(&worktrees_dir).unwrap();
|
||
|
||
// Orphan: the mount_target is gone but its PARENT dir exists (home is
|
||
// restored), so the scanner can prove it's orphaned and reclaim it.
|
||
let mount_parent = tmp.path().join("home-restored");
|
||
std::fs::create_dir(&mount_parent).unwrap();
|
||
let meta = btrfs::BtrfsSnapshotMetadata {
|
||
kind: std::borrow::Cow::Borrowed("btrfs"),
|
||
snapshot_path: worktrees_dir.join("wt-abc"),
|
||
mount_target: mount_parent.join("gone-target"),
|
||
created_at: "1740000000s-since-epoch".to_string(),
|
||
};
|
||
let meta_path = worktrees_dir.join("wt-abc.btrfs-meta.json");
|
||
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
|
||
|
||
let entries = vec![MountEntry {
|
||
mount_id: 1,
|
||
parent_id: 0,
|
||
root: "/".to_string(),
|
||
mount_point: tmp.path().to_path_buf(),
|
||
fs_type: "btrfs".to_string(),
|
||
source: "/dev/loop0".to_string(),
|
||
super_options: String::new(),
|
||
}];
|
||
|
||
let report = cleanup_orphaned_btrfs_snapshots_inner(&entries);
|
||
assert_eq!(report.removed, 1);
|
||
// snapshot_path doesn't exist as dir, so no btrfs delete attempted
|
||
assert_eq!(report.btrfs_deleted, 0);
|
||
assert!(!meta_path.exists(), "metadata should be cleaned up");
|
||
}
|
||
|
||
/// A snapshot whose `mount_target` parent dir is missing can't be proven orphaned,
|
||
/// so the scanner must skip it rather than destroy one about to be re-exposed.
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn test_cleanup_orphaned_btrfs_skips_when_mount_target_parent_missing() {
|
||
use crate::btrfs;
|
||
use crate::mount_info::MountEntry;
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let worktrees_dir = tmp.path().join("worktrees");
|
||
std::fs::create_dir(&worktrees_dir).unwrap();
|
||
|
||
// mount_target lives under a home dir not yet restored. Hermetic: the parent
|
||
// is a path inside this tempdir that the test never creates.
|
||
let snapshot_path = worktrees_dir.join("wt-live");
|
||
std::fs::create_dir(&snapshot_path).unwrap();
|
||
let unrestored_home = tmp.path().join("unrestored-home");
|
||
let mount_target = unrestored_home.join(".grok/worktrees/x/wt-live");
|
||
assert!(
|
||
!mount_target.parent().unwrap().exists(),
|
||
"precondition: mount_target parent must be absent"
|
||
);
|
||
let meta = btrfs::BtrfsSnapshotMetadata {
|
||
kind: std::borrow::Cow::Borrowed("btrfs"),
|
||
snapshot_path: snapshot_path.clone(),
|
||
mount_target,
|
||
created_at: "1740000000s-since-epoch".to_string(),
|
||
};
|
||
let meta_path = worktrees_dir.join("wt-live.btrfs-meta.json");
|
||
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
|
||
|
||
let entries = vec![MountEntry {
|
||
mount_id: 1,
|
||
parent_id: 0,
|
||
root: "/".to_string(),
|
||
mount_point: tmp.path().to_path_buf(),
|
||
fs_type: "btrfs".to_string(),
|
||
source: "/dev/loop0".to_string(),
|
||
super_options: String::new(),
|
||
}];
|
||
|
||
let report = cleanup_orphaned_btrfs_snapshots_inner(&entries);
|
||
assert_eq!(
|
||
report.removed, 0,
|
||
"must not reclaim while orphan status is unprovable"
|
||
);
|
||
// The guard must skip cleanly: without it, a non-btrfs tempdir host would
|
||
// instead error-class this as "outside scanned storage" (errors == 1).
|
||
assert_eq!(report.errors, 0, "guard must skip cleanly, not error-class");
|
||
assert!(
|
||
meta_path.exists(),
|
||
"metadata must be preserved for a later scan"
|
||
);
|
||
// The guard `continue`s before any delete, so the snapshot dir is untouched.
|
||
assert!(snapshot_path.exists(), "snapshot must not be deleted");
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn test_cleanup_orphaned_btrfs_skips_active() {
|
||
use crate::btrfs;
|
||
use crate::mount_info::MountEntry;
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let worktrees_dir = tmp.path().join("worktrees");
|
||
std::fs::create_dir(&worktrees_dir).unwrap();
|
||
|
||
let mount_target = std::path::PathBuf::from("/home/user/.grok/worktrees/active-wt");
|
||
|
||
let meta = btrfs::BtrfsSnapshotMetadata {
|
||
kind: std::borrow::Cow::Borrowed("btrfs"),
|
||
snapshot_path: worktrees_dir.join("active-wt"),
|
||
mount_target: mount_target.clone(),
|
||
created_at: "1740000000s-since-epoch".to_string(),
|
||
};
|
||
let meta_path = worktrees_dir.join("active-wt.btrfs-meta.json");
|
||
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
|
||
|
||
// mount_target appears in the mount entries (simulates active bind mount)
|
||
let entries = vec![
|
||
MountEntry {
|
||
mount_id: 1,
|
||
parent_id: 0,
|
||
root: "/".to_string(),
|
||
mount_point: tmp.path().to_path_buf(),
|
||
fs_type: "btrfs".to_string(),
|
||
source: "/dev/loop0".to_string(),
|
||
super_options: String::new(),
|
||
},
|
||
MountEntry {
|
||
mount_id: 2,
|
||
parent_id: 1,
|
||
root: "/worktrees/active-wt".to_string(),
|
||
mount_point: mount_target,
|
||
fs_type: "btrfs".to_string(),
|
||
source: "/dev/loop0".to_string(),
|
||
super_options: String::new(),
|
||
},
|
||
];
|
||
|
||
let report = cleanup_orphaned_btrfs_snapshots_inner(&entries);
|
||
assert_eq!(report.removed, 0, "active snapshot should not be removed");
|
||
assert!(
|
||
meta_path.exists(),
|
||
"metadata for active snapshot should remain"
|
||
);
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn test_cleanup_orphaned_btrfs_skips_active_symlink() {
|
||
// Current layout: the live worktree is a SYMLINK to the snapshot and
|
||
// never appears in mountinfo. The orphan scanner must recognize it as
|
||
// active (resolves to snapshot_path) and NOT delete it.
|
||
use crate::btrfs;
|
||
use crate::mount_info::MountEntry;
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let worktrees_dir = tmp.path().join("worktrees");
|
||
std::fs::create_dir(&worktrees_dir).unwrap();
|
||
|
||
// The snapshot dir (a plain dir here) and a live symlink pointing at it.
|
||
let snapshot_path = worktrees_dir.join("live-wt");
|
||
std::fs::create_dir(&snapshot_path).unwrap();
|
||
let mount_target = tmp.path().join("worktree-symlink");
|
||
std::os::unix::fs::symlink(&snapshot_path, &mount_target).unwrap();
|
||
|
||
let meta = btrfs::BtrfsSnapshotMetadata {
|
||
kind: std::borrow::Cow::Borrowed("btrfs"),
|
||
snapshot_path: snapshot_path.clone(),
|
||
mount_target: mount_target.clone(),
|
||
created_at: "1740000000s-since-epoch".to_string(),
|
||
};
|
||
let meta_path = worktrees_dir.join("live-wt.btrfs-meta.json");
|
||
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
|
||
|
||
// No mount entry references the symlink — only the btrfs mount itself.
|
||
let entries = vec![MountEntry {
|
||
mount_id: 1,
|
||
parent_id: 0,
|
||
root: "/".to_string(),
|
||
mount_point: tmp.path().to_path_buf(),
|
||
fs_type: "btrfs".to_string(),
|
||
source: "/dev/loop0".to_string(),
|
||
super_options: String::new(),
|
||
}];
|
||
|
||
let report = cleanup_orphaned_btrfs_snapshots_inner(&entries);
|
||
assert_eq!(report.removed, 0, "active symlink worktree must be kept");
|
||
assert!(
|
||
meta_path.exists(),
|
||
"metadata for live worktree should remain"
|
||
);
|
||
assert!(snapshot_path.exists(), "snapshot must not be deleted");
|
||
assert!(
|
||
mount_target.symlink_metadata().is_ok(),
|
||
"live symlink must not be removed"
|
||
);
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn test_symlink_resolves_to() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let target = tmp.path().join("worktrees").join("snap");
|
||
std::fs::create_dir_all(&target).unwrap();
|
||
|
||
// Absolute-target symlink resolving to `target` → true.
|
||
let abs_link = tmp.path().join("abs-link");
|
||
std::os::unix::fs::symlink(&target, &abs_link).unwrap();
|
||
assert!(symlink_resolves_to(&abs_link, &target));
|
||
|
||
// Relative-target symlink resolving (via link.parent()) to `target` → true.
|
||
let rel_link = tmp.path().join("rel-link");
|
||
std::os::unix::fs::symlink(std::path::Path::new("worktrees/snap"), &rel_link).unwrap();
|
||
assert!(symlink_resolves_to(&rel_link, &target));
|
||
|
||
// Symlink resolving elsewhere → false.
|
||
let other = tmp.path().join("other");
|
||
std::fs::create_dir(&other).unwrap();
|
||
let wrong_link = tmp.path().join("wrong-link");
|
||
std::os::unix::fs::symlink(&other, &wrong_link).unwrap();
|
||
assert!(!symlink_resolves_to(&wrong_link, &target));
|
||
|
||
// Non-symlink path → false.
|
||
assert!(!symlink_resolves_to(&target, &target));
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn test_try_btrfs_remove_from_metadata_finds_match() {
|
||
use crate::btrfs;
|
||
use crate::mount_info::MountEntry;
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let worktrees_dir = tmp.path().join("worktrees");
|
||
std::fs::create_dir(&worktrees_dir).unwrap();
|
||
|
||
let mount_target = tmp.path().join("mount-target");
|
||
std::fs::create_dir(&mount_target).unwrap();
|
||
|
||
let meta = btrfs::BtrfsSnapshotMetadata {
|
||
kind: std::borrow::Cow::Borrowed("btrfs"),
|
||
snapshot_path: worktrees_dir.join("snap-abc"),
|
||
mount_target: mount_target.clone(),
|
||
created_at: "1740000000s-since-epoch".to_string(),
|
||
};
|
||
let meta_path = worktrees_dir.join("snap-abc.btrfs-meta.json");
|
||
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
|
||
|
||
let entries = vec![MountEntry {
|
||
mount_id: 1,
|
||
parent_id: 0,
|
||
root: "/".to_string(),
|
||
mount_point: tmp.path().to_path_buf(),
|
||
fs_type: "btrfs".to_string(),
|
||
source: "/dev/loop0".to_string(),
|
||
super_options: String::new(),
|
||
}];
|
||
|
||
// `snapshot_path` is intentionally never created, so the privileged
|
||
// `btrfs subvolume delete` is gated out (btrfs is unavailable in CI; real
|
||
// subvolume deletion is exercised only on a btrfs-capable host). The
|
||
// discriminating signals here are the metadata + dir cleanup.
|
||
let report = try_btrfs_remove_from_metadata_inner(&mount_target, &entries, None)
|
||
.unwrap()
|
||
.expect("should find metadata match");
|
||
// Nothing was deleted (snapshot absent) and this dir branch unmounts
|
||
// nothing on an already-unmounted dir.
|
||
assert!(!report.used_btrfs_delete);
|
||
assert!(!report.unmounted_bind);
|
||
assert!(!meta_path.exists(), "metadata should be cleaned up");
|
||
assert!(!mount_target.exists(), "dir worktree should be removed");
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn test_try_btrfs_remove_from_metadata_removes_symlink_target() {
|
||
use crate::btrfs;
|
||
use crate::mount_info::MountEntry;
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let worktrees_dir = tmp.path().join("worktrees");
|
||
std::fs::create_dir(&worktrees_dir).unwrap();
|
||
|
||
// The on-disk snapshot dir (a plain dir here — no real btrfs subvolume,
|
||
// so deletion is skipped, but the symlink + metadata must be cleaned up).
|
||
let snapshot_path = worktrees_dir.join("snap-link");
|
||
|
||
// The worktree is exposed at `mount_target` via a symlink to the snapshot.
|
||
let mount_target = tmp.path().join("worktree-symlink");
|
||
std::os::unix::fs::symlink(&snapshot_path, &mount_target).unwrap();
|
||
assert!(mount_target.is_symlink());
|
||
|
||
let meta = btrfs::BtrfsSnapshotMetadata {
|
||
kind: std::borrow::Cow::Borrowed("btrfs"),
|
||
snapshot_path: snapshot_path.clone(),
|
||
mount_target: mount_target.clone(),
|
||
created_at: "1740000000s-since-epoch".to_string(),
|
||
};
|
||
let meta_path = worktrees_dir.join("snap-link.btrfs-meta.json");
|
||
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
|
||
|
||
let entries = vec![MountEntry {
|
||
mount_id: 1,
|
||
parent_id: 0,
|
||
root: "/".to_string(),
|
||
mount_point: tmp.path().to_path_buf(),
|
||
fs_type: "btrfs".to_string(),
|
||
source: "/dev/loop0".to_string(),
|
||
super_options: String::new(),
|
||
}];
|
||
|
||
// NOTE: `snapshot_path` is intentionally never created here, so the
|
||
// privileged `btrfs subvolume delete` is gated out (btrfs is unavailable
|
||
// in CI). This test covers the symlink-vs-dir branch selection and the
|
||
// symlink + metadata cleanup; the real subvolume deletion is exercised
|
||
// only on a btrfs-capable host.
|
||
let result = try_btrfs_remove_from_metadata_inner(&mount_target, &entries, None);
|
||
assert!(result.is_ok());
|
||
let report = result.unwrap().expect("should find metadata match");
|
||
// The symlink branch never unmounts a bind mount.
|
||
assert!(!report.unmounted_bind);
|
||
// No leak: the symlink and the metadata file are both gone.
|
||
assert!(
|
||
mount_target.symlink_metadata().is_err(),
|
||
"symlink worktree should be removed"
|
||
);
|
||
assert!(!meta_path.exists(), "metadata should be cleaned up");
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn test_try_btrfs_remove_from_metadata_removes_legacy_dir_target() {
|
||
// Legacy bind-mount layout: `mount_target` is a real (empty) directory.
|
||
// Exercises the non-symlink `else` branch (umount is a no-op on an
|
||
// already-unmounted empty dir, then `remove_dir`).
|
||
use crate::btrfs;
|
||
use crate::mount_info::MountEntry;
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let worktrees_dir = tmp.path().join("worktrees");
|
||
std::fs::create_dir(&worktrees_dir).unwrap();
|
||
|
||
let mount_target = tmp.path().join("mount-target");
|
||
std::fs::create_dir(&mount_target).unwrap();
|
||
assert!(!mount_target.is_symlink());
|
||
|
||
let meta = btrfs::BtrfsSnapshotMetadata {
|
||
kind: std::borrow::Cow::Borrowed("btrfs"),
|
||
snapshot_path: worktrees_dir.join("snap-dir"),
|
||
mount_target: mount_target.clone(),
|
||
created_at: "1740000000s-since-epoch".to_string(),
|
||
};
|
||
let meta_path = worktrees_dir.join("snap-dir.btrfs-meta.json");
|
||
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
|
||
|
||
let entries = vec![MountEntry {
|
||
mount_id: 1,
|
||
parent_id: 0,
|
||
root: "/".to_string(),
|
||
mount_point: tmp.path().to_path_buf(),
|
||
fs_type: "btrfs".to_string(),
|
||
source: "/dev/loop0".to_string(),
|
||
super_options: String::new(),
|
||
}];
|
||
|
||
let report = try_btrfs_remove_from_metadata_inner(&mount_target, &entries, None)
|
||
.unwrap()
|
||
.expect("should find metadata match");
|
||
// No leak: the directory and metadata are both gone.
|
||
assert!(!mount_target.exists(), "dir worktree should be removed");
|
||
assert!(!meta_path.exists(), "metadata should be cleaned up");
|
||
let _ = report;
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn test_try_btrfs_remove_symlink_to_non_btrfs_target() {
|
||
// A symlink whose target is not a btrfs subvolume: try_btrfs_remove
|
||
// should remove the symlink and fall through (Ok(None) overall once the
|
||
// now-removed path is no longer a btrfs subvolume).
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let target = tmp.path().join("plain-target");
|
||
std::fs::create_dir(&target).unwrap();
|
||
|
||
let link = tmp.path().join("worktree-symlink");
|
||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||
assert!(link.is_symlink());
|
||
|
||
let result = try_btrfs_remove(&link, None);
|
||
assert!(result.is_ok());
|
||
assert!(
|
||
result.unwrap().is_none(),
|
||
"should fall through for non-btrfs"
|
||
);
|
||
assert!(
|
||
link.symlink_metadata().is_err(),
|
||
"non-btrfs symlink should be removed before falling through"
|
||
);
|
||
// The target itself is untouched by the symlink removal.
|
||
assert!(target.exists());
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn test_try_btrfs_remove_from_metadata_no_match() {
|
||
use crate::mount_info::MountEntry;
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let worktrees_dir = tmp.path().join("worktrees");
|
||
std::fs::create_dir(&worktrees_dir).unwrap();
|
||
|
||
let entries = vec![MountEntry {
|
||
mount_id: 1,
|
||
parent_id: 0,
|
||
root: "/".to_string(),
|
||
mount_point: tmp.path().to_path_buf(),
|
||
fs_type: "btrfs".to_string(),
|
||
source: "/dev/loop0".to_string(),
|
||
super_options: String::new(),
|
||
}];
|
||
|
||
let result = try_btrfs_remove_from_metadata_inner(
|
||
std::path::Path::new("/nonexistent/target"),
|
||
&entries,
|
||
None,
|
||
);
|
||
assert!(result.is_ok());
|
||
assert!(result.unwrap().is_none());
|
||
}
|
||
|
||
#[cfg(feature = "metadata")]
|
||
mod metadata_integration {
|
||
use super::*;
|
||
use crate::db::{ListFilter, WorktreeDb, WorktreeKind};
|
||
|
||
fn db_at(tmp: &tempfile::TempDir) -> WorktreeDb {
|
||
WorktreeDb::open(tmp.path()).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn register_worktree_writes_correct_fields() {
|
||
// Isolate GROK_HOME so register_worktree's open_default write lands
|
||
// in our own DB (lock + private tmp + restore via the fixture).
|
||
let fx = crate::db::GrokHomeFixture::new();
|
||
|
||
// Unique basename → unique id, so a concurrent open_default writer
|
||
// (GROK_HOME is process-global) can't INSERT-OR-REPLACE our row.
|
||
let wt_path = fx.home.join("register-fields-wt");
|
||
std::fs::create_dir(&wt_path).unwrap();
|
||
// register_worktree stores the canonical path (/var → /private/var on macOS).
|
||
let wt_canon = dunce::canonicalize(&wt_path).unwrap_or_else(|_| wt_path.clone());
|
||
|
||
super::super::register_worktree(
|
||
&wt_path,
|
||
std::path::Path::new("/src/repo"),
|
||
WorktreeKind::Session,
|
||
"linked",
|
||
"main",
|
||
"abc123",
|
||
Some("test-session".to_string()),
|
||
None,
|
||
None,
|
||
);
|
||
|
||
// register_worktree wrote to open_default, which resolves to fx.home.
|
||
// Filter to OUR record by path: concurrent tests may add rows here.
|
||
let db = WorktreeDb::open(&fx.home).unwrap();
|
||
let mine: Vec<_> = db
|
||
.list(&ListFilter::default())
|
||
.unwrap()
|
||
.into_iter()
|
||
.filter(|r| r.path == wt_canon)
|
||
.collect();
|
||
assert_eq!(mine.len(), 1);
|
||
assert_eq!(mine[0].kind, WorktreeKind::Session);
|
||
assert_eq!(mine[0].session_id.as_deref(), Some("test-session"));
|
||
assert_eq!(mine[0].creation_mode, "linked");
|
||
assert_eq!(mine[0].head_commit.as_deref(), Some("abc123"));
|
||
assert!(mine[0].creator_pid.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn unregister_worktree_removes_by_path() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let wt_path = tmp.path().join("wt");
|
||
|
||
let record = crate::db::WorktreeRecord {
|
||
id: "test-wt".to_string(),
|
||
path: wt_path.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 100,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&record).unwrap();
|
||
assert_eq!(db.list(&ListFilter::default()).unwrap().len(), 1);
|
||
|
||
db.unregister_by_path(&wt_path).unwrap();
|
||
assert!(db.list(&ListFilter::default()).unwrap().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn creation_mode_as_db_str_matches_schema() {
|
||
assert_eq!(CreationMode::Linked.as_db_str(), "linked");
|
||
assert_eq!(CreationMode::Standalone.as_db_str(), "standalone");
|
||
assert_eq!(CreationMode::GitCheckout.as_db_str(), "git");
|
||
}
|
||
|
||
#[test]
|
||
fn gc_removes_dead_records() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
|
||
// Register a record with a nonexistent path
|
||
let record = crate::db::WorktreeRecord {
|
||
id: "dead-1".to_string(),
|
||
path: "/nonexistent/worktree".into(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 100,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&record).unwrap();
|
||
|
||
let report = gc::gc_worktrees(&db, &gc::GcOptions::default()).unwrap();
|
||
assert_eq!(report.dead_removed, 1);
|
||
|
||
let all = db
|
||
.list(&ListFilter {
|
||
include_dead: true,
|
||
..Default::default()
|
||
})
|
||
.unwrap();
|
||
assert!(all.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn gc_skips_alive_pids() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let my_pid = std::process::id();
|
||
|
||
let record = crate::db::WorktreeRecord {
|
||
id: "alive-wt".to_string(),
|
||
path: "/nonexistent/path".into(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: Some(my_pid),
|
||
created_at: 1, // very old
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&record).unwrap();
|
||
|
||
// sweep_dead will mark it dead (path doesn't exist),
|
||
// but gc with max_age should still check liveness for expiry.
|
||
// Since the path doesn't exist, sweep_dead marks it dead first,
|
||
// then dead_removed cleans it. Let's use a real existing path instead.
|
||
let dir = tmp.path().join("real-wt");
|
||
std::fs::create_dir(&dir).unwrap();
|
||
let mut record2 = record.clone();
|
||
record2.id = "alive-wt2".to_string();
|
||
record2.path = dir.clone();
|
||
db.register(&record2).unwrap();
|
||
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: false,
|
||
dry_run: false,
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
// Our PID is alive, so the real-path worktree should be skipped
|
||
assert_eq!(report.skipped_alive, 1);
|
||
// The nonexistent-path one gets swept to dead then removed
|
||
assert_eq!(report.dead_removed, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn gc_dry_run_preserves_records() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
|
||
let record = crate::db::WorktreeRecord {
|
||
id: "dry-1".to_string(),
|
||
path: "/nonexistent".into(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 100,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&record).unwrap();
|
||
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
dry_run: true,
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(report.dead_removed, 1); // counted as would-be-removed
|
||
// Dry run must NOT mutate: the record is still present AND still
|
||
// Alive (it was never swept to Dead).
|
||
let all = db
|
||
.list(&ListFilter {
|
||
include_dead: true,
|
||
..Default::default()
|
||
})
|
||
.unwrap();
|
||
assert_eq!(all.len(), 1);
|
||
assert_eq!(all[0].status, crate::db::WorktreeStatus::Alive);
|
||
}
|
||
|
||
#[test]
|
||
fn gc_force_overrides_liveness() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
|
||
let dir = tmp.path().join("force-wt");
|
||
std::fs::create_dir(&dir).unwrap();
|
||
|
||
let record = crate::db::WorktreeRecord {
|
||
id: "force-1".to_string(),
|
||
path: dir.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: Some(std::process::id()), // our own PID
|
||
created_at: 1, // very old
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&record).unwrap();
|
||
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: true,
|
||
dry_run: false,
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(report.expired_removed, 1);
|
||
assert_eq!(report.skipped_alive, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn gc_clamps_extreme_max_age_without_overflow() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let dir = tmp.path().join("fresh-wt");
|
||
std::fs::create_dir(&dir).unwrap();
|
||
let record = crate::db::WorktreeRecord {
|
||
id: "fresh-1".to_string(),
|
||
path: dir.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: i64::MAX,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&record).unwrap();
|
||
// `now - i64::MIN` would overflow/wrap the cutoff into the future and
|
||
// reclaim everything; the clamp treats any negative age as 0 so the
|
||
// cutoff is `now` and nothing fresh is reclaimed (and no panic).
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(i64::MIN),
|
||
force: false,
|
||
dry_run: false,
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(report.expired_removed, 0);
|
||
assert!(dir.exists());
|
||
}
|
||
|
||
#[test]
|
||
fn gc_honors_last_accessed_time() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let fresh = tmp.path().join("fresh-access");
|
||
let stale = tmp.path().join("stale-access");
|
||
std::fs::create_dir(&fresh).unwrap();
|
||
std::fs::create_dir(&stale).unwrap();
|
||
let base = crate::db::WorktreeRecord {
|
||
id: String::new(),
|
||
path: std::path::PathBuf::new(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None, // no liveness guard: isolate the age logic
|
||
created_at: 1, // both are old by creation time
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "fresh".to_string(),
|
||
path: fresh.clone(),
|
||
last_accessed_at: Some(i64::MAX), // touched within the window
|
||
..base.clone()
|
||
})
|
||
.unwrap();
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "stale".to_string(),
|
||
path: stale.clone(),
|
||
last_accessed_at: Some(1), // never re-touched
|
||
..base
|
||
})
|
||
.unwrap();
|
||
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: false,
|
||
dry_run: false,
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
assert!(
|
||
fresh.exists(),
|
||
"a recently accessed worktree must survive despite an old created_at"
|
||
);
|
||
assert!(
|
||
!stale.exists(),
|
||
"a never-touched expired worktree must be reclaimed"
|
||
);
|
||
assert_eq!(report.expired_removed, 1);
|
||
}
|
||
|
||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||
fn scan_has_cwd_under(prefix: &std::path::Path) -> bool {
|
||
match gc::live_process_cwds() {
|
||
gc::LiveCwdScan::Ok(cwds) => cwds.iter().any(|p| {
|
||
p.starts_with(prefix)
|
||
|| dunce::canonicalize(p).is_ok_and(|c| c.starts_with(prefix))
|
||
}),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||
fn wait_until(pred: impl Fn() -> bool) -> bool {
|
||
use std::time::Duration;
|
||
for _ in 0..200 {
|
||
if pred() {
|
||
return true;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(10));
|
||
}
|
||
false
|
||
}
|
||
|
||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||
#[test]
|
||
fn gc_cwd_guard_skips_then_reclaims_expired_worktree() {
|
||
let _cwd_lock = crate::api::cwd_test_guard();
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let dir = tmp.path().join("cwd-wt");
|
||
let nested = dir.join("nested");
|
||
std::fs::create_dir_all(&nested).unwrap();
|
||
let record = crate::db::WorktreeRecord {
|
||
id: "cwd-1".to_string(),
|
||
path: dir.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&record).unwrap();
|
||
|
||
let mut child = std::process::Command::new("sleep")
|
||
.arg("30")
|
||
.current_dir(&nested)
|
||
.spawn()
|
||
.expect("spawn sleep");
|
||
let want = dunce::canonicalize(&nested).unwrap();
|
||
assert!(
|
||
wait_until(|| scan_has_cwd_under(&want)),
|
||
"live_process_cwds must observe the parked child before GC"
|
||
);
|
||
|
||
let opts = gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: false,
|
||
dry_run: false,
|
||
..Default::default()
|
||
};
|
||
let guarded = gc::gc_worktrees(&db, &opts).unwrap();
|
||
assert_eq!(
|
||
guarded.skipped_alive, 1,
|
||
"a live in-tree CWD must protect the expired worktree"
|
||
);
|
||
assert_eq!(guarded.expired_removed, 0);
|
||
assert!(dir.exists());
|
||
|
||
// Once the process exits, the same expired worktree is reclaimed.
|
||
child.kill().ok();
|
||
child.wait().ok();
|
||
assert!(
|
||
wait_until(|| !scan_has_cwd_under(&want)),
|
||
"child CWD must leave the scan after exit before reclaim"
|
||
);
|
||
let reclaimed = gc::gc_worktrees(&db, &opts).unwrap();
|
||
assert_eq!(
|
||
reclaimed.expired_removed, 1,
|
||
"no live process inside ⇒ the expired worktree is reclaimed"
|
||
);
|
||
assert!(!dir.exists());
|
||
}
|
||
|
||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||
#[test]
|
||
fn gc_age_path_fail_closed_when_scan_unusable() {
|
||
// Pure gate: Failed scan blocks age; force still allows.
|
||
assert!(!gc::age_path_cwd_usable(&gc::LiveCwdScan::Failed, false));
|
||
assert!(gc::age_path_cwd_usable(&gc::LiveCwdScan::Failed, true));
|
||
}
|
||
|
||
#[test]
|
||
fn gc_dry_run_with_max_age_does_not_remove_expired() {
|
||
// An expired worktree whose dir exists must be previewed (counted)
|
||
// but never removed under dry_run.
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
|
||
let dir = tmp.path().join("expired-wt");
|
||
std::fs::create_dir(&dir).unwrap();
|
||
|
||
let record = crate::db::WorktreeRecord {
|
||
id: "expired-1".to_string(),
|
||
path: dir.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None, // no liveness guard
|
||
created_at: 1, // very old
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&record).unwrap();
|
||
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: true,
|
||
dry_run: true,
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
report.expired_removed, 1,
|
||
"dry run should count the candidate"
|
||
);
|
||
// No mutation: the dir and the (still Alive) record both survive.
|
||
assert!(dir.exists(), "dry run must not remove the worktree dir");
|
||
let all = db.list(&ListFilter::default()).unwrap();
|
||
assert_eq!(all.len(), 1);
|
||
assert_eq!(all[0].status, crate::db::WorktreeStatus::Alive);
|
||
}
|
||
|
||
#[test]
|
||
fn gc_dry_run_missing_and_expired_counted_once() {
|
||
// A record that is Alive, has a MISSING path, AND is expired must be
|
||
// counted EXACTLY once (a real run sweeps it to dead and unregisters
|
||
// it before the expired loop). It belongs to dead_removed, not both.
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
|
||
let record = crate::db::WorktreeRecord {
|
||
id: "missing-expired".to_string(),
|
||
path: "/nonexistent/expired-wt".into(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1, // very old → expired
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&record).unwrap();
|
||
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: true,
|
||
dry_run: true,
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
report.dead_removed, 1,
|
||
"missing path counts as would-be-dead"
|
||
);
|
||
assert_eq!(
|
||
report.expired_removed, 0,
|
||
"must not also be counted in expired_removed"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn gc_expired_failed_removal_keeps_record() {
|
||
// When the expired worktree can't be removed, expired_removed must
|
||
// NOT be counted and the DB record must survive (so it stays
|
||
// visible to a later gc).
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
|
||
// A regular file makes remove_worktree's `remove_dir_all` fail
|
||
// (ENOTDIR) deterministically, even as root.
|
||
let path = tmp.path().join("doomed-wt");
|
||
std::fs::write(&path, b"not a dir").unwrap();
|
||
|
||
let record = crate::db::WorktreeRecord {
|
||
id: "doomed-1".to_string(),
|
||
path: path.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&record).unwrap();
|
||
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: true,
|
||
dry_run: false,
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
report.expired_removed, 0,
|
||
"a failed removal must not be counted"
|
||
);
|
||
assert_eq!(
|
||
report.remove_failed, 1,
|
||
"a failed removal must be surfaced in remove_failed"
|
||
);
|
||
let all = db.list(&ListFilter::default()).unwrap();
|
||
assert_eq!(all.len(), 1, "record must survive a failed removal");
|
||
assert!(path.exists(), "the un-removable path is still present");
|
||
}
|
||
|
||
/// True if a record with `path` exists in the DB (assert on our own
|
||
/// record rather than total count: other tests may write to the same
|
||
/// open_default DB concurrently). Matches `register_worktree`'s
|
||
/// canonical path storage (/var vs /private/var on macOS).
|
||
fn record_present(db: &WorktreeDb, path: &std::path::Path) -> bool {
|
||
let canon = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||
db.list(&ListFilter::default())
|
||
.unwrap()
|
||
.iter()
|
||
.any(|r| r.path == path || r.path == canon)
|
||
}
|
||
|
||
#[test]
|
||
fn db_record_survives_failed_removal() {
|
||
// remove_worktree must keep the DB record when the on-disk removal
|
||
// fails, so the worktree isn't lost from tracking while leaking on
|
||
// disk (unregister only after a successful removal).
|
||
let fx = crate::db::GrokHomeFixture::new();
|
||
|
||
// A regular file makes remove_dir_all fail (ENOTDIR) deterministically.
|
||
let wt_path = fx.home.join("doomed-wt");
|
||
std::fs::write(&wt_path, b"not a dir").unwrap();
|
||
|
||
// Register via the production registration path (uses open_default).
|
||
super::super::register_worktree(
|
||
&wt_path,
|
||
std::path::Path::new("/src/repo"),
|
||
WorktreeKind::Session,
|
||
"linked",
|
||
"main",
|
||
"abc123",
|
||
None,
|
||
None,
|
||
None,
|
||
);
|
||
let db = WorktreeDb::open(&fx.home).unwrap();
|
||
assert!(
|
||
record_present(&db, &wt_path),
|
||
"precondition: record registered"
|
||
);
|
||
|
||
assert!(
|
||
crate::remove_worktree(&wt_path).is_err(),
|
||
"removing a non-directory path must fail"
|
||
);
|
||
|
||
assert!(
|
||
record_present(&db, &wt_path),
|
||
"record must survive a failed removal"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn db_record_removed_after_successful_removal() {
|
||
// The success direction: a removable worktree must still be
|
||
// unregistered from the DB (catches a regression dropping the
|
||
// unregister).
|
||
xai_test_utils::require_git!();
|
||
use xai_test_utils::git::{git_commit_all, init_git_repo};
|
||
|
||
let fx = crate::db::GrokHomeFixture::new();
|
||
|
||
// A real repo + a real worktree so remove_worktree succeeds on disk.
|
||
let repo = fx.home.join("repo");
|
||
std::fs::create_dir(&repo).unwrap();
|
||
init_git_repo(&repo);
|
||
std::fs::write(repo.join("f.txt"), "x").unwrap();
|
||
git_commit_all(&repo, "init");
|
||
let wt_path = fx.home.join("live-wt");
|
||
crate::WorktreeBuilder::new(&repo, &wt_path)
|
||
.create()
|
||
.unwrap();
|
||
|
||
super::super::register_worktree(
|
||
&wt_path,
|
||
&repo,
|
||
WorktreeKind::Session,
|
||
"linked",
|
||
"main",
|
||
"abc123",
|
||
None,
|
||
None,
|
||
None,
|
||
);
|
||
let db = WorktreeDb::open(&fx.home).unwrap();
|
||
assert!(
|
||
record_present(&db, &wt_path),
|
||
"precondition: record registered"
|
||
);
|
||
|
||
crate::remove_worktree(&wt_path).unwrap();
|
||
|
||
assert!(!wt_path.exists(), "worktree dir should be gone");
|
||
assert!(
|
||
!record_present(&db, &wt_path),
|
||
"a successful removal must unregister the DB record"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn gc_with_delegate_removes_expired_and_unregisters() {
|
||
// gc_worktrees_with_delegate threads the delegate through the expired
|
||
// path and, on a successful removal, counts it and drops the record.
|
||
// (The delegate's btrfs fallback only fires on a real btrfs-delete
|
||
// failure, which needs a btrfs host; here the plain-dir fast path
|
||
// succeeds, so the mock's delete_snapshot is not called.)
|
||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||
|
||
// GROK_HOME == the gc DB dir so remove_worktree's open_default
|
||
// unregister hits the same DB the gc record lives in.
|
||
let fx = crate::db::GrokHomeFixture::new();
|
||
let db = WorktreeDb::open(&fx.home).unwrap();
|
||
|
||
let dir = fx.home.join("expired-wt");
|
||
std::fs::create_dir(&dir).unwrap();
|
||
let record = crate::db::WorktreeRecord {
|
||
id: "expired-del-1".to_string(),
|
||
path: dir.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1, // very old → expired
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&record).unwrap();
|
||
|
||
let deletes = Arc::new(AtomicUsize::new(0));
|
||
let delegate: Arc<dyn BtrfsDelegate> = Arc::new(super::super::RecordingDelegate {
|
||
snapshot_path: fx.home.join("unused-snap"),
|
||
worktree_path: dir.clone(),
|
||
deletes: Arc::clone(&deletes),
|
||
});
|
||
|
||
let report = gc::gc_worktrees_with_delegate(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: true,
|
||
dry_run: false,
|
||
..Default::default()
|
||
},
|
||
Some(delegate),
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
report.expired_removed, 1,
|
||
"expired worktree should be reclaimed"
|
||
);
|
||
assert!(!dir.exists(), "the worktree dir should be removed");
|
||
assert!(
|
||
db.get("expired-del-1").unwrap().is_none(),
|
||
"the DB record should be unregistered after a successful removal"
|
||
);
|
||
// Plain-dir fast path succeeds without needing the delegate fallback.
|
||
assert_eq!(deletes.load(Ordering::Relaxed), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn gc_report_serde_round_trip() {
|
||
let report = gc::GcReport {
|
||
dead_removed: 3,
|
||
expired_removed: 1,
|
||
skipped_alive: 2,
|
||
remove_failed: 4,
|
||
};
|
||
let json = serde_json::to_string(&report).unwrap();
|
||
let deser: gc::GcReport = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(deser.dead_removed, 3);
|
||
assert_eq!(deser.expired_removed, 1);
|
||
assert_eq!(deser.skipped_alive, 2);
|
||
assert_eq!(deser.remove_failed, 4);
|
||
}
|
||
|
||
#[test]
|
||
fn gc_options_serde_round_trip() {
|
||
let opts = gc::GcOptions {
|
||
max_age_secs: Some(86400),
|
||
force: true,
|
||
dry_run: false,
|
||
protect_paths: vec![std::path::PathBuf::from("/tmp/p")],
|
||
skip_kinds: vec![WorktreeKind::Manual],
|
||
max_age_by_kind: [(WorktreeKind::Subagent, Some(3600))].into_iter().collect(),
|
||
};
|
||
let json = serde_json::to_string(&opts).unwrap();
|
||
let deser: gc::GcOptions = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(deser.max_age_secs, Some(86400));
|
||
assert!(deser.force);
|
||
assert!(!deser.dry_run);
|
||
assert_eq!(deser.protect_paths, opts.protect_paths);
|
||
assert_eq!(deser.skip_kinds, vec![WorktreeKind::Manual]);
|
||
assert_eq!(
|
||
deser.max_age_by_kind.get(&WorktreeKind::Subagent),
|
||
Some(&Some(3600))
|
||
);
|
||
// Absent new fields deserialize as empty (old agents).
|
||
let legacy = r#"{"max_age_secs":1,"force":false,"dry_run":true}"#;
|
||
let legacy_opts: gc::GcOptions = serde_json::from_str(legacy).unwrap();
|
||
assert!(legacy_opts.protect_paths.is_empty());
|
||
assert!(legacy_opts.skip_kinds.is_empty());
|
||
assert!(legacy_opts.max_age_by_kind.is_empty());
|
||
|
||
// JSON null value in map → never-expire (None).
|
||
let with_null = r#"{
|
||
"max_age_secs": 100,
|
||
"force": false,
|
||
"dry_run": false,
|
||
"max_age_by_kind": {"manual": null, "subagent": 3600}
|
||
}"#;
|
||
let null_opts: gc::GcOptions = serde_json::from_str(with_null).unwrap();
|
||
assert_eq!(
|
||
null_opts.max_age_by_kind.get(&WorktreeKind::Manual),
|
||
Some(&None)
|
||
);
|
||
assert_eq!(
|
||
null_opts.max_age_by_kind.get(&WorktreeKind::Subagent),
|
||
Some(&Some(3600))
|
||
);
|
||
let round = serde_json::to_string(&null_opts).unwrap();
|
||
let back: gc::GcOptions = serde_json::from_str(&round).unwrap();
|
||
assert_eq!(back.max_age_by_kind, null_opts.max_age_by_kind);
|
||
}
|
||
|
||
#[test]
|
||
fn gc_protect_paths_skips_age_expiry_including_dry_run() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let dir = tmp.path().join("protected-wt");
|
||
let nested = dir.join("nested");
|
||
std::fs::create_dir_all(&nested).unwrap();
|
||
let record = crate::db::WorktreeRecord {
|
||
id: "prot-1".to_string(),
|
||
path: dir.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&record).unwrap();
|
||
|
||
// Protect via a nested path (same canonicalize rules as cwd_within).
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: false,
|
||
dry_run: true,
|
||
protect_paths: vec![nested],
|
||
skip_kinds: vec![],
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
report.expired_removed, 0,
|
||
"dry_run must not count protect_paths hits as would-expire"
|
||
);
|
||
assert_eq!(report.skipped_alive, 1);
|
||
assert!(dir.exists());
|
||
|
||
// Without protect, dry_run would-count it.
|
||
let unguarded = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: false,
|
||
dry_run: true,
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(unguarded.expired_removed, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn gc_protect_paths_pre_remove_recheck() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let dir = tmp.path().join("prot-real");
|
||
std::fs::create_dir(&dir).unwrap();
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "prot-real".to_string(),
|
||
path: dir.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
})
|
||
.unwrap();
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: false,
|
||
dry_run: false,
|
||
protect_paths: vec![dir.clone()],
|
||
skip_kinds: vec![],
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(report.expired_removed, 0);
|
||
assert_eq!(report.skipped_alive, 1);
|
||
assert!(dir.exists(), "protect_paths must block real remove");
|
||
}
|
||
|
||
#[test]
|
||
fn force_does_not_override_skip_kinds() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let dir = tmp.path().join("manual-force");
|
||
std::fs::create_dir(&dir).unwrap();
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "manual-force".into(),
|
||
path: dir.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Manual,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
})
|
||
.unwrap();
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: true,
|
||
dry_run: false,
|
||
protect_paths: vec![],
|
||
skip_kinds: vec![WorktreeKind::Manual],
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
dir.exists(),
|
||
"force must not age-expire kinds listed in skip_kinds"
|
||
);
|
||
assert_eq!(report.expired_removed, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn gc_skip_kinds_manual_age_only_not_dead() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let manual_dir = tmp.path().join("manual-alive");
|
||
let session_dir = tmp.path().join("session-alive");
|
||
std::fs::create_dir(&manual_dir).unwrap();
|
||
std::fs::create_dir(&session_dir).unwrap();
|
||
let base = crate::db::WorktreeRecord {
|
||
id: String::new(),
|
||
path: std::path::PathBuf::new(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "manual".into(),
|
||
path: manual_dir.clone(),
|
||
kind: WorktreeKind::Manual,
|
||
..base.clone()
|
||
})
|
||
.unwrap();
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "session".into(),
|
||
path: session_dir.clone(),
|
||
kind: WorktreeKind::Session,
|
||
..base.clone()
|
||
})
|
||
.unwrap();
|
||
// Dead manual (missing path) still reclaimed on dead path.
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "manual-dead".into(),
|
||
path: "/nonexistent/manual-dead".into(),
|
||
kind: WorktreeKind::Manual,
|
||
..base
|
||
})
|
||
.unwrap();
|
||
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: false,
|
||
dry_run: false,
|
||
protect_paths: vec![],
|
||
skip_kinds: vec![WorktreeKind::Manual],
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
manual_dir.exists(),
|
||
"Manual must not age-expire when in skip_kinds"
|
||
);
|
||
assert!(
|
||
!session_dir.exists(),
|
||
"Session must still age-expire under skip_kinds=[Manual]"
|
||
);
|
||
assert_eq!(report.expired_removed, 1);
|
||
assert!(
|
||
report.skipped_alive >= 1,
|
||
"expired skip_kinds must surface in skipped_alive"
|
||
);
|
||
assert_eq!(report.dead_removed, 1, "dead Manual still unregisters");
|
||
|
||
// dry_run must not count skipped kinds as would-expire.
|
||
let dir2 = tmp.path().join("manual-dry");
|
||
std::fs::create_dir(&dir2).unwrap();
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "manual-dry".into(),
|
||
path: dir2.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Manual,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
})
|
||
.unwrap();
|
||
let dry = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: false,
|
||
dry_run: true,
|
||
protect_paths: vec![],
|
||
skip_kinds: vec![WorktreeKind::Manual],
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
dry.expired_removed, 0,
|
||
"dry_run must not count skip_kinds as expired"
|
||
);
|
||
assert!(
|
||
dry.skipped_alive >= 1,
|
||
"dry_run still counts expired skip_kinds as skipped_alive"
|
||
);
|
||
assert!(dir2.exists());
|
||
}
|
||
|
||
#[test]
|
||
fn per_kind_max_age_expires_session_not_manual() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let session_dir = tmp.path().join("session-alive");
|
||
let manual_dir = tmp.path().join("manual-alive");
|
||
std::fs::create_dir(&session_dir).unwrap();
|
||
std::fs::create_dir(&manual_dir).unwrap();
|
||
let base = crate::db::WorktreeRecord {
|
||
id: String::new(),
|
||
path: std::path::PathBuf::new(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "session".into(),
|
||
path: session_dir.clone(),
|
||
kind: WorktreeKind::Session,
|
||
..base.clone()
|
||
})
|
||
.unwrap();
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "manual".into(),
|
||
path: manual_dir.clone(),
|
||
kind: WorktreeKind::Manual,
|
||
..base
|
||
})
|
||
.unwrap();
|
||
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: false,
|
||
dry_run: false,
|
||
max_age_by_kind: [(WorktreeKind::Manual, None)].into_iter().collect(),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
!session_dir.exists(),
|
||
"session must age-expire under default max_age"
|
||
);
|
||
assert!(
|
||
manual_dir.exists(),
|
||
"manual never-expire via max_age_by_kind"
|
||
);
|
||
assert_eq!(report.expired_removed, 1);
|
||
assert!(report.skipped_alive >= 1);
|
||
}
|
||
|
||
#[test]
|
||
fn per_kind_shorter_ttl_expires_subagent_keeps_session() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let now = crate::db::now_epoch_secs();
|
||
// Subagent last active 2h ago; session last active 2h ago.
|
||
// subagent TTL=1h → expire; session default=7d → keep.
|
||
let sub_dir = tmp.path().join("sub");
|
||
let sess_dir = tmp.path().join("sess");
|
||
std::fs::create_dir(&sub_dir).unwrap();
|
||
std::fs::create_dir(&sess_dir).unwrap();
|
||
let age = 2 * 3600;
|
||
let base = crate::db::WorktreeRecord {
|
||
id: String::new(),
|
||
path: std::path::PathBuf::new(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: now - age,
|
||
last_accessed_at: Some(now - age),
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "sub".into(),
|
||
path: sub_dir.clone(),
|
||
kind: WorktreeKind::Subagent,
|
||
..base.clone()
|
||
})
|
||
.unwrap();
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "sess".into(),
|
||
path: sess_dir.clone(),
|
||
kind: WorktreeKind::Session,
|
||
..base
|
||
})
|
||
.unwrap();
|
||
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(7 * 86400),
|
||
force: false,
|
||
dry_run: false,
|
||
max_age_by_kind: [(WorktreeKind::Subagent, Some(3600))].into_iter().collect(),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert!(!sub_dir.exists(), "subagent past 1h TTL must expire");
|
||
assert!(sess_dir.exists(), "session within 7d must stay");
|
||
assert_eq!(report.expired_removed, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn force_does_not_override_max_age_by_kind_never() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let dir = tmp.path().join("manual-never");
|
||
std::fs::create_dir(&dir).unwrap();
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "manual-never".into(),
|
||
path: dir.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Manual,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
})
|
||
.unwrap();
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: true,
|
||
dry_run: false,
|
||
max_age_by_kind: [(WorktreeKind::Manual, None)].into_iter().collect(),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
dir.exists(),
|
||
"force must not age-expire never-kinds in max_age_by_kind"
|
||
);
|
||
assert_eq!(report.expired_removed, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn dry_run_counts_per_kind_cutoffs() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let now = crate::db::now_epoch_secs();
|
||
let age = 2 * 3600;
|
||
let sub_dir = tmp.path().join("sub-dry");
|
||
let sess_dir = tmp.path().join("sess-dry");
|
||
let man_dir = tmp.path().join("man-dry");
|
||
std::fs::create_dir(&sub_dir).unwrap();
|
||
std::fs::create_dir(&sess_dir).unwrap();
|
||
std::fs::create_dir(&man_dir).unwrap();
|
||
let base = crate::db::WorktreeRecord {
|
||
id: String::new(),
|
||
path: std::path::PathBuf::new(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: now - age,
|
||
last_accessed_at: Some(now - age),
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "sub-dry".into(),
|
||
path: sub_dir.clone(),
|
||
kind: WorktreeKind::Subagent,
|
||
..base.clone()
|
||
})
|
||
.unwrap();
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "sess-dry".into(),
|
||
path: sess_dir.clone(),
|
||
kind: WorktreeKind::Session,
|
||
..base.clone()
|
||
})
|
||
.unwrap();
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "man-dry".into(),
|
||
path: man_dir.clone(),
|
||
kind: WorktreeKind::Manual,
|
||
..base
|
||
})
|
||
.unwrap();
|
||
|
||
let dry = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(7 * 86400),
|
||
force: false,
|
||
dry_run: true,
|
||
max_age_by_kind: [
|
||
(WorktreeKind::Subagent, Some(3600)),
|
||
(WorktreeKind::Manual, None),
|
||
]
|
||
.into_iter()
|
||
.collect(),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
dry.expired_removed, 1,
|
||
"only subagent past its kind TTL is would-expire"
|
||
);
|
||
assert!(sub_dir.exists() && sess_dir.exists() && man_dir.exists());
|
||
|
||
// max_age_secs=0: session+subagent would-expire; manual never → skipped.
|
||
let dry0 = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: false,
|
||
dry_run: true,
|
||
max_age_by_kind: [
|
||
(WorktreeKind::Subagent, Some(3600)),
|
||
(WorktreeKind::Manual, None),
|
||
]
|
||
.into_iter()
|
||
.collect(),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(dry0.expired_removed, 2);
|
||
assert!(dry0.skipped_alive >= 1);
|
||
}
|
||
|
||
#[test]
|
||
fn max_age_by_kind_only_without_default_enables_age_path() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let pool_dir = tmp.path().join("pool-only");
|
||
let sess_dir = tmp.path().join("sess-unlisted");
|
||
std::fs::create_dir(&pool_dir).unwrap();
|
||
std::fs::create_dir(&sess_dir).unwrap();
|
||
let base = crate::db::WorktreeRecord {
|
||
id: String::new(),
|
||
path: std::path::PathBuf::new(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Session,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
};
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "pool-only".into(),
|
||
path: pool_dir.clone(),
|
||
kind: WorktreeKind::Pool,
|
||
..base.clone()
|
||
})
|
||
.unwrap();
|
||
// Session has no map entry and max_age_secs=None → must not expire.
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "sess-unlisted".into(),
|
||
path: sess_dir.clone(),
|
||
kind: WorktreeKind::Session,
|
||
..base
|
||
})
|
||
.unwrap();
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: None,
|
||
force: false,
|
||
dry_run: false,
|
||
max_age_by_kind: [(WorktreeKind::Pool, Some(0))].into_iter().collect(),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert!(!pool_dir.exists(), "listed kind expires");
|
||
assert!(
|
||
sess_dir.exists(),
|
||
"unlisted kind must not expire when max_age_secs=None"
|
||
);
|
||
assert_eq!(report.expired_removed, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn skip_kinds_beats_max_age_by_kind_on_same_kind() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let dir = tmp.path().join("manual-conflict");
|
||
std::fs::create_dir(&dir).unwrap();
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "manual-conflict".into(),
|
||
path: dir.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Manual,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
})
|
||
.unwrap();
|
||
let opts = gc::GcOptions {
|
||
max_age_secs: Some(0),
|
||
force: true,
|
||
dry_run: false,
|
||
skip_kinds: vec![WorktreeKind::Manual],
|
||
max_age_by_kind: [(WorktreeKind::Manual, Some(0))].into_iter().collect(),
|
||
..Default::default()
|
||
};
|
||
let report = gc::gc_worktrees(&db, &opts).unwrap();
|
||
assert!(
|
||
dir.exists(),
|
||
"skip_kinds must win over max_age_by_kind Some(secs)"
|
||
);
|
||
assert_eq!(report.expired_removed, 0);
|
||
|
||
let dry = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
dry_run: true,
|
||
force: false,
|
||
..opts
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
dry.expired_removed, 0,
|
||
"dry_run must not count skip-winning kinds as would-expire"
|
||
);
|
||
assert!(dry.skipped_alive >= 1);
|
||
}
|
||
|
||
#[test]
|
||
fn configurable_manual_can_expire() {
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let db = db_at(&tmp);
|
||
let dir = tmp.path().join("manual-expire");
|
||
std::fs::create_dir(&dir).unwrap();
|
||
db.register(&crate::db::WorktreeRecord {
|
||
id: "manual-exp".into(),
|
||
path: dir.clone(),
|
||
source_repo: "/repo".into(),
|
||
repo_name: "repo".to_string(),
|
||
kind: WorktreeKind::Manual,
|
||
creation_mode: "linked".to_string(),
|
||
git_ref: None,
|
||
head_commit: None,
|
||
session_id: None,
|
||
creator_pid: None,
|
||
created_at: 1,
|
||
last_accessed_at: None,
|
||
status: crate::db::WorktreeStatus::Alive,
|
||
metadata: None,
|
||
})
|
||
.unwrap();
|
||
let report = gc::gc_worktrees(
|
||
&db,
|
||
&gc::GcOptions {
|
||
max_age_secs: Some(7 * 86400),
|
||
force: false,
|
||
dry_run: false,
|
||
max_age_by_kind: [(WorktreeKind::Manual, Some(0))].into_iter().collect(),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
!dir.exists(),
|
||
"manual with explicit max_age_by_kind must be configurable to expire"
|
||
);
|
||
assert_eq!(report.expired_removed, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn db_stats_serde_round_trip() {
|
||
let stats = crate::db::DbStats {
|
||
total_records: 10,
|
||
alive_count: 7,
|
||
dead_count: 3,
|
||
db_file_bytes: 4096,
|
||
};
|
||
let json = serde_json::to_string(&stats).unwrap();
|
||
let deser: crate::db::DbStats = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(deser.total_records, 10);
|
||
assert_eq!(deser.alive_count, 7);
|
||
assert_eq!(deser.dead_count, 3);
|
||
assert_eq!(deser.db_file_bytes, 4096);
|
||
}
|
||
}
|
||
}
|