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

4747 lines
180 KiB
Rust
Raw Normal View History

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