use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::Deserialize;
use tracing::{debug, info, warn};
use super::decommission::is_session_worktree;
use super::index_delete_guard::{DeleteOutcome, DestructiveIndexDelete};
use super::manager::SessionManager;
use crate::daemon::search_rpc::{self, SearchRpcError};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
const ORPHAN_GRACE: Duration = Duration::from_secs(300);
pub(super) fn disposable_workspace_index_id(
workspace_path: Option<&Path>,
workspace_owned: bool,
) -> Option<String> {
let ws = workspace_path?;
if !(workspace_owned || is_session_worktree(ws)) {
return None;
}
let root = trusty_common::resolve_project_root(ws);
let id = trusty_common::derive_index_id(&root);
if id.trim().is_empty() { None } else { Some(id) }
}
pub(super) async fn delete_search_index_best_effort(index_id: &str) {
let Some(deleter) = DestructiveIndexDelete::acquire() else {
return;
};
match deleter.delete(index_id).await {
DeleteOutcome::Removed => {
info!(
index_id,
"decommission: removed trusty-search index (#2033)"
);
}
DeleteOutcome::NotRemoved(detail) => {
warn!(
index_id,
"decommission: trusty-search kept the index: {detail}"
);
}
DeleteOutcome::Refused { code, message } => {
warn!(
index_id,
code, "decommission: trusty-search index delete refused: {message}"
);
}
DeleteOutcome::Transport(e) => {
warn!(
index_id,
"decommission: trusty-search index delete failed: {e}"
);
}
}
}
#[derive(Debug, Deserialize)]
struct IndexDetailRow {
id: String,
root_path: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct IndexDetailsResponse {
#[serde(default)]
indexes: Vec<IndexDetailRow>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct IndexSnapshot {
pub id: String,
pub root_path: PathBuf,
pub chunk_count: u64,
}
pub(super) fn is_orphan_index(
entry: &IndexSnapshot,
active_workspace_paths: &HashSet<PathBuf>,
grace: Duration,
) -> bool {
if !is_session_worktree(&entry.root_path) {
return false;
}
if active_workspace_paths.contains(&entry.root_path) {
return false;
}
let canonical =
std::fs::canonicalize(&entry.root_path).unwrap_or_else(|_| entry.root_path.clone());
if active_workspace_paths.contains(&canonical) {
return false;
}
if !entry.root_path.exists() {
return true;
}
entry.chunk_count == 0 && !root_is_within_grace(&entry.root_path, grace)
}
fn root_is_within_grace(root: &Path, grace: Duration) -> bool {
let Ok(meta) = std::fs::metadata(root) else {
return false;
};
let Ok(stamp) = meta.created().or_else(|_| meta.modified()) else {
return false;
};
stamp.elapsed().map(|age| age < grace).unwrap_or(true) }
async fn fetch_index_details(socket: &Path) -> anyhow::Result<Vec<(String, PathBuf)>> {
let value = search_rpc::call_at(
socket,
search_rpc::METHOD_INDEXES_LIST,
serde_json::json!({ "details": true }),
REQUEST_TIMEOUT,
)
.await?;
let body: IndexDetailsResponse = serde_json::from_value(value)?;
Ok(body
.indexes
.into_iter()
.filter_map(|row| row.root_path.map(|rp| (row.id, PathBuf::from(rp))))
.collect())
}
async fn probe_chunk_count(socket: &Path, id: &str) -> Option<u64> {
match search_rpc::call_at(
socket,
search_rpc::METHOD_INDEX_STATUS,
serde_json::json!({ "index_id": id }),
REQUEST_TIMEOUT,
)
.await
{
Ok(body) => body.get("chunk_count").and_then(serde_json::Value::as_u64),
Err(e) => {
debug!(index_id = %id, "search-index-gc: status probe failed: {e:#}");
None
}
}
}
impl SessionManager {
async fn active_workspace_path_set(&self) -> HashSet<PathBuf> {
let mut set = HashSet::new();
for p in self
.list()
.await
.into_iter()
.filter_map(|r| r.workspace_path)
{
if let Ok(canonical) = std::fs::canonicalize(&p) {
set.insert(canonical);
}
set.insert(p);
}
set
}
pub async fn sweep_orphaned_search_indexes(
&self,
dry_run: bool,
) -> anyhow::Result<Vec<String>> {
let deleter = if dry_run {
None
} else {
match DestructiveIndexDelete::acquire() {
Some(d) => Some(d),
None => return Ok(Vec::new()),
}
};
let socket = match search_rpc::search_socket() {
Ok(socket) => socket,
Err(e) => {
debug!(
"cannot resolve the trusty-search socket; skipping index orphan sweep: {e:#}"
);
return Ok(Vec::new());
}
};
let details = match fetch_index_details(&socket).await {
Ok(details) => details,
Err(e) if e.downcast_ref::<SearchRpcError>().is_none() => {
debug!("trusty-search daemon unreachable; skipping index orphan sweep: {e:#}");
return Ok(Vec::new());
}
Err(e) => return Err(e),
};
let active_workspace_paths = self.active_workspace_path_set().await;
let mut candidates = Vec::new();
for (id, root_path) in details {
if !is_session_worktree(&root_path) {
continue;
}
let Some(chunk_count) = probe_chunk_count(&socket, &id).await else {
continue;
};
let snapshot = IndexSnapshot {
id: id.clone(),
root_path,
chunk_count,
};
if is_orphan_index(&snapshot, &active_workspace_paths, ORPHAN_GRACE) {
candidates.push(id);
}
}
let Some(deleter) = deleter else {
for id in &candidates {
info!(index_id = %id, "search-index-gc (dry-run): would remove orphaned/0-chunk index");
}
return Ok(candidates);
};
let mut removed = Vec::new();
for id in candidates {
match deleter.delete(&id).await {
DeleteOutcome::Removed => {
info!(index_id = %id, "search-index-gc: removed orphaned/0-chunk index");
removed.push(id);
}
DeleteOutcome::NotRemoved(detail) => {
warn!(index_id = %id, "search-index-gc: trusty-search kept the index: {detail}");
}
DeleteOutcome::Refused { code, message } => {
warn!(index_id = %id, code, "search-index-gc: delete refused: {message}");
}
DeleteOutcome::Transport(e) => {
warn!(index_id = %id, "search-index-gc: delete failed: {e}");
}
}
}
Ok(removed)
}
pub async fn reap_orphaned_search_indexes(&self) -> anyhow::Result<Vec<String>> {
self.sweep_orphaned_search_indexes(false).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn worktree_path(root: &Path, leaf: &str) -> PathBuf {
root.join("owner")
.join("repo")
.join(".worktrees")
.join(leaf)
}
#[test]
fn disposable_index_id_none_when_no_workspace() {
assert_eq!(disposable_workspace_index_id(None, true), None);
assert_eq!(disposable_workspace_index_id(None, false), None);
}
#[test]
fn disposable_index_id_none_for_unowned_non_worktree() {
let path = Path::new("/Users/dev/my-real-project");
assert_eq!(disposable_workspace_index_id(Some(path), false), None);
}
#[test]
fn disposable_index_id_none_for_a_main_checkout_pm_session() {
let tmp = tempfile::tempdir().unwrap();
let main_checkout = tmp.path().join("bobmatnyc").join("trusty-tools");
std::fs::create_dir_all(main_checkout.join(".git")).unwrap();
assert_eq!(
disposable_workspace_index_id(Some(&main_checkout), true).as_deref(),
Some("trusty-tools"),
"fixture precondition: the path must be index-id-resolvable, or the \
assertion below would pass for the wrong reason"
);
assert_eq!(
disposable_workspace_index_id(Some(&main_checkout), false),
None,
"a PM session on the project's main checkout must never have the \
project's own search index reclaimed (ADR-0036)"
);
}
#[test]
fn disposable_index_id_derives_from_worktree_path() {
let tmp = tempfile::tempdir().unwrap();
let wt = worktree_path(tmp.path(), "my-feature-worktree");
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(wt.join(".git"), "gitdir: /somewhere/else").unwrap();
let id = disposable_workspace_index_id(Some(&wt), false);
assert_eq!(id.as_deref(), Some("my-feature-worktree"));
}
#[test]
fn disposable_index_id_derives_from_owned_clone_path() {
let tmp = tempfile::tempdir().unwrap();
let clone_root = tmp.path().join("owner").join("cloned-repo");
std::fs::create_dir_all(clone_root.join(".git")).unwrap();
let id = disposable_workspace_index_id(Some(&clone_root), true);
assert_eq!(id.as_deref(), Some("cloned-repo"));
}
#[test]
fn is_orphan_index_false_for_non_worktree_root() {
let entry = IndexSnapshot {
id: "persistent-project".into(),
root_path: PathBuf::from("/Users/dev/persistent-project"),
chunk_count: 0,
};
assert!(!is_orphan_index(&entry, &HashSet::new(), Duration::ZERO));
}
#[test]
fn is_orphan_index_false_when_claimed_by_active_session() {
let tmp = tempfile::tempdir().unwrap();
let wt = worktree_path(tmp.path(), "live-session");
std::fs::create_dir_all(&wt).unwrap();
let entry = IndexSnapshot {
id: "live-session".into(),
root_path: wt.clone(),
chunk_count: 0,
};
let active: HashSet<PathBuf> = [wt].into_iter().collect();
assert!(!is_orphan_index(&entry, &active, Duration::ZERO));
}
#[test]
fn is_orphan_index_true_for_zero_chunk_unclaimed_worktree() {
let tmp = tempfile::tempdir().unwrap();
let wt = worktree_path(tmp.path(), "stub-session");
std::fs::create_dir_all(&wt).unwrap();
let entry = IndexSnapshot {
id: "stub-session".into(),
root_path: wt,
chunk_count: 0,
};
assert!(is_orphan_index(&entry, &HashSet::new(), Duration::ZERO));
}
#[test]
fn is_orphan_index_true_for_deleted_root_path() {
let entry = IndexSnapshot {
id: "gone-session".into(),
root_path: PathBuf::from("/nonexistent/owner/repo/.worktrees/gone-session"),
chunk_count: 500,
};
assert!(is_orphan_index(&entry, &HashSet::new(), Duration::ZERO));
}
#[test]
fn is_orphan_index_false_for_populated_unclaimed_worktree_that_still_exists() {
let tmp = tempfile::tempdir().unwrap();
let wt = worktree_path(tmp.path(), "maybe-orphaned");
std::fs::create_dir_all(&wt).unwrap();
let entry = IndexSnapshot {
id: "maybe-orphaned".into(),
root_path: wt,
chunk_count: 42,
};
assert!(!is_orphan_index(&entry, &HashSet::new(), Duration::ZERO));
}
#[test]
fn is_orphan_index_spares_a_freshly_created_worktree_root() {
let tmp = tempfile::tempdir().unwrap();
let wt = worktree_path(tmp.path(), "just-created");
std::fs::create_dir_all(&wt).unwrap();
let entry = IndexSnapshot {
id: "just-created".into(),
root_path: wt,
chunk_count: 0,
};
assert!(
!is_orphan_index(&entry, &HashSet::new(), ORPHAN_GRACE),
"a worktree created seconds ago reads 0 chunks because its walk is \
still running — deleting it here is the #5060 race"
);
}
#[test]
fn is_orphan_index_grace_does_not_protect_a_root_that_is_gone() {
let entry = IndexSnapshot {
id: "vanished".into(),
root_path: PathBuf::from("/nonexistent/owner/repo/.worktrees/vanished"),
chunk_count: 0,
};
assert!(is_orphan_index(&entry, &HashSet::new(), ORPHAN_GRACE));
}
}