Files
Kigi-CLI/crates/codegen/kigi-fast-worktree/src/api.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

3608 lines
137 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 `~/.kigi/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");
kigi_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");
kigi_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");
kigi_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");
kigi_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 `$KIGI_SHARE_DIR` 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() {
kigi_test_utils::require_git!();
use kigi_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() {
kigi_test_utils::require_git!();
use kigi_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 ~/.kigi/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 (~/.kigi/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() {
kigi_test_utils::require_git!();
use kigi_test_utils::git::{git_commit_all, init_git_repo};
// Isolate KIGI_SHARE_DIR 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/.kigi/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(".kigi/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/.kigi/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 KIGI_SHARE_DIR 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
// (KIGI_SHARE_DIR 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).
kigi_test_utils::require_git!();
use kigi_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};
// KIGI_SHARE_DIR == 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);
}
}
}