grok-build-upstream-mirror/crates/codegen/xai-fast-worktree/src/api.rs

3607 lines
137 KiB
Rust
Raw Normal View History

//! 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;
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(&reg_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;
}
};
let record = db::WorktreeRecord {
id: worktree_id.unwrap_or_else(|| db::id_from_path(worktree_path)),
path: worktree_path.to_path_buf(),
source_repo: source.to_path_buf(),
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 _ = db.unregister_by_path(worktree_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::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Result;
use crate::BtrfsDelegate;
use crate::db::{ListFilter, WorktreeDb, WorktreeStatus};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct GcOptions {
pub max_age_secs: Option<i64>,
pub force: bool,
pub dry_run: bool,
}
#[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,
// TODO(v2): untracked_found, untracked_registered (via rebuild_worktree_db),
// stale_registrations_cleaned (stale .git/worktrees/ cleanup)
}
/// Decode a `kill(pid, 0)` outcome into liveness. `ret == 0` ⇒ the process
/// exists. Otherwise: `ESRCH` ⇒ no such process (dead); anything else
/// (notably `EPERM`/`EACCES` — exists but owned by another user) ⇒ alive.
/// `kill -0`'s exit status can't distinguish `EPERM` from `ESRCH` and wrongly
/// reports `EPERM` as dead; split out so this is unit-testable.
#[cfg(target_os = "linux")]
fn pid_alive_from_kill(ret: i32, errno: i32) -> bool {
ret == 0 || errno != libc::ESRCH
}
fn is_pid_alive(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
// pid 0 targets the caller's process group and pid > i32::MAX wraps to
// a negative pid_t (also a process group); neither is a real tracked
// pid (creator_pid is always our own process id), so treat as dead.
if pid == 0 || pid > i32::MAX as u32 {
return false;
}
// A null signal (sig 0) runs the kernel's existence/permission check
// without delivering a signal.
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(target_os = "linux"))]
{
// No libc dependency off Linux; fall back to `kill -0` exit status.
let mut cmd = std::process::Command::new("kill");
xai_tty_utils::detach_std_command(&mut cmd);
cmd.stdin(std::process::Stdio::null());
cmd.args(["-0", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
}
/// Physical CWDs of every readable running process, via the Linux `/proc`
/// scan. Empty on non-Linux — the creator-PID guard still applies.
/// Dep-free on purpose (avoids re-adding a process-listing crate just for
/// this guard).
#[cfg(target_os = "linux")]
fn live_process_cwds() -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir("/proc") else {
return Vec::new();
};
entries
.filter_map(Result::ok)
// Only numeric `/proc/<pid>` entries expose a `cwd` symlink.
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.parse::<u32>().is_ok())
})
// An unreadable link (process exited / not permitted) means nothing
// is parked there, so drop it.
.filter_map(|e| std::fs::read_link(e.path().join("cwd")).ok())
.collect()
}
#[cfg(not(target_os = "linux"))]
fn live_process_cwds() -> Vec<PathBuf> {
Vec::new()
}
/// True if any `live_cwds` entry sits inside `wt_path`. Kernel CWD links are
/// physical paths, so also match the canonicalized worktree path — a
/// symlinked `$GROK_HOME` or custom worktree path would otherwise never match.
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| cwd.starts_with(wt_path) || cwd.starts_with(&wt_canon))
}
/// Effective freshness timestamp: the more recent of creation and last
/// access (last_accessed_at is never read as older than created_at).
fn last_active(rec: &crate::db::WorktreeRecord) -> i64 {
rec.last_accessed_at
.unwrap_or(rec.created_at)
.max(rec.created_at)
}
/// Guarded — must not be reclaimed — when the creator process is still
/// running or any live process has its CWD inside the tree.
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)
}
/// Reclaimable only when expired (older than `cutoff` by [`last_active`]) and
/// unguarded. Used for the per-candidate re-check against a freshly-read row.
fn is_reclaimable(rec: &crate::db::WorktreeRecord, cutoff: i64, live_cwds: &[PathBuf]) -> bool {
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).
if let Some(max_age) = opts.max_age_secs {
// Clamp: a negative max_age must not push the cutoff into the future
// (which would expire everything), and an extreme value must not
// overflow the subtraction.
let cutoff = now.saturating_sub(max_age.max(0));
// One process-table scan, reused by the in-tree liveness guard below;
// skipped under --force, where the guard never fires.
let live_cwds = if opts.force {
Vec::new()
} else {
live_process_cwds()
};
let alive = db.list(&ListFilter::default())?;
for rec in alive {
// A worktree touched within the age window must not expire;
// callers bump last_accessed_at on use.
if last_active(&rec) >= cutoff {
continue;
}
let path = Path::new(&rec.path);
// Cheap first-pass guard against the upfront snapshot; the
// per-candidate re-check below re-confirms against a fresh row.
if !opts.force && is_guarded(&rec, &live_cwds) {
report.skipped_alive += 1;
continue;
}
// Dry run: count the candidate without touching disk or DB. Skip
// a missing path — a real run sweeps it to dead first, so it's
// already counted in dead_removed (don't double-count here).
if opts.dry_run {
if path.exists() {
report.expired_removed += 1;
}
continue;
}
// Re-evaluate against a freshly-read row + live process scan right
// before the destructive step: the list snapshot is stale (earlier
// removals take time), so a concurrent touch_worktree_for_cwd
// (bumps last_accessed_at), a revived creator, or a process that
// chdir'd into the tree must still protect it.
if !opts.force {
match db.get_by_id(&rec.id) {
Ok(Some(fresh)) => {
if !is_reclaimable(&fresh, cutoff, &live_process_cwds()) {
report.skipped_alive += 1;
continue;
}
}
// Fail closed: a vanished row (concurrently unregistered)
// or an unreadable DB must not green-light a remove.
Ok(None) | Err(_) => continue,
}
}
if path.exists() {
match super::remove_worktree_with_delegate(path, delegate.clone()) {
Ok(_) => report.expired_removed += 1,
// A failed remove (e.g. EPERM) leaves the record tracked
// for a later retry; surface it instead of reporting zero.
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) {
// Path already gone: drop the stale record.
report.expired_removed += 1;
}
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[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");
}
#[test]
fn is_pid_alive_true_for_running_processes() {
assert!(is_pid_alive(std::process::id()));
// PID 1 (init) always exists.
#[cfg(target_os = "linux")]
assert!(is_pid_alive(1), "init must be detected as alive");
}
#[cfg(target_os = "linux")]
#[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));
}
#[test]
fn is_pid_alive_false_for_reaped_child() {
// A fully reaped child's pid is gone (ESRCH) and must read as dead.
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));
}
#[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, &[]));
}
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 cutoff = 1_000;
// Expired (old created_at, never accessed) and unguarded → reclaimable.
assert!(is_reclaimable(&rec_at("/no/such/wt", 1), cutoff, &[]));
// A recent last_accessed_at within the window protects it.
let mut fresh = rec_at("/no/such/wt", 1);
fresh.last_accessed_at = Some(cutoff + 10);
assert!(!is_reclaimable(&fresh, cutoff, &[]));
// A live creator pid protects it.
let mut live_creator = rec_at("/no/such/wt", 1);
live_creator.creator_pid = Some(std::process::id());
assert!(!is_reclaimable(&live_creator, cutoff, &[]));
// 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),
cutoff,
&[inside]
));
}
}
}
#[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();
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_path)
.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), // everything is expired
force: false,
dry_run: false,
},
)
.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,
},
)
.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,
},
)
.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,
},
)
.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(target_os = "linux")]
#[test]
fn gc_cwd_guard_skips_then_reclaims_expired_worktree() {
use std::time::Duration;
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, // creator gone: only the CWD guard can protect it
created_at: 1, // very old → expired
last_accessed_at: None,
status: crate::db::WorktreeStatus::Alive,
metadata: None,
};
db.register(&record).unwrap();
// A live process parked inside the expired worktree subtree.
let mut child = std::process::Command::new("sleep")
.arg("30")
.current_dir(&nested)
.spawn()
.expect("spawn sleep");
// Wait for the kernel to reflect the child's CWD before GC scans.
let want = dunce::canonicalize(&nested).unwrap();
let link = format!("/proc/{}/cwd", child.id());
for _ in 0..200 {
if std::fs::read_link(&link).is_ok_and(|p| p.starts_with(&want)) {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
let opts = gc::GcOptions {
max_age_secs: Some(0),
force: false,
dry_run: false,
};
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();
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());
}
#[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,
},
)
.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,
},
)
.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,
},
)
.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).
fn record_present(db: &WorktreeDb, path: &std::path::Path) -> bool {
db.list(&ListFilter::default())
.unwrap()
.iter()
.any(|r| r.path == path)
}
#[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,
},
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,
};
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);
}
#[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);
}
}
}