use std::path::Path;
use tracing::{info, warn};
use crate::core::trusty_tools_config::{TrustyToolsConfig, workspace_root};
use super::manager::{ManagedError, SessionManager};
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
use super::search_gc;
use super::workspace_guard::is_safe_to_remove;
pub(crate) const WORKTREE_SENTINEL_FILE: &str = ".trusty-mpm-worktree";
pub(crate) const WORKTREES_DIRNAME: &str = ".worktrees";
pub(crate) const GIT_WORKTREE_REMOVE_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(30);
pub(super) fn is_session_worktree(path: &Path) -> bool {
path.parent()
.and_then(|p| p.file_name())
.map(|n| n == WORKTREES_DIRNAME)
.unwrap_or(false)
}
pub(super) fn remove_session_worktree(path: &Path) -> bool {
if !path.exists() {
return true;
}
let sentinel = path.join(WORKTREE_SENTINEL_FILE);
if !sentinel.exists() {
if !is_session_worktree(path) {
warn!(
path = %path.display(),
sentinel = WORKTREE_SENTINEL_FILE,
"decommission: refusing worktree removal — no SM ownership sentinel \
and path is not under .worktrees/; skipping conservatively"
);
return false;
}
warn!(
path = %path.display(),
sentinel = WORKTREE_SENTINEL_FILE,
"decommission: sentinel absent; falling back to convention check \
(backward-compat with pre-sentinel worktrees)"
);
}
let repo_root = match path.parent().and_then(|p| p.parent()) {
Some(r) => r,
None => {
warn!(
path = %path.display(),
"decommission: cannot determine repo root from worktree path — skipping git removal"
);
return false;
}
};
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["worktree", "remove", "--force"])
.arg(path)
.output();
let git_success = match out {
Ok(o) if o.status.success() => {
info!(path = %path.display(), "decommission: git worktree removed (incl. ref)");
true
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
warn!(
path = %path.display(),
"decommission: git worktree remove --force failed ({}): {stderr}; \
falling back to remove_dir_all",
o.status
);
if let Err(e) = std::fs::remove_dir_all(path) {
warn!(path = %path.display(), "decommission: fallback remove_dir_all failed: {e}");
return false;
}
false }
Err(e) => {
warn!(
path = %path.display(),
"decommission: failed to spawn git for worktree removal: {e}; \
falling back to remove_dir_all"
);
if let Err(e2) = std::fs::remove_dir_all(path) {
warn!(path = %path.display(), "decommission: fallback remove_dir_all also failed: {e2}");
return false;
}
false
}
};
if git_success {
let prune_out = std::process::Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["worktree", "prune"])
.output();
if let Err(e) = prune_out {
warn!(root = %repo_root.display(), "decommission: git worktree prune failed: {e}");
}
if let Some(session_name) = path.file_name().and_then(|n| n.to_str()) {
let branch = crate::core::worktree_naming::worktree_branch_for(session_name);
let branch_out = std::process::Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["branch", "-D"])
.arg(&branch)
.output();
match branch_out {
Ok(o) if o.status.success() => {
info!(
path = %path.display(),
"decommission: git branch -D {:?} (session ref cleaned)",
branch
);
}
Ok(o) => {
tracing::debug!(
path = %path.display(),
"decommission: git branch -D {:?} not needed: {}",
branch,
String::from_utf8_lossy(&o.stderr).trim()
);
}
Err(e) => {
warn!(
path = %path.display(),
"decommission: git branch -D failed to spawn: {e}"
);
}
}
}
}
true }
impl SessionManager {
pub async fn decommission(
&self,
id: &ManagedSessionId,
) -> Result<(SessionRecord, bool), ManagedError> {
let config = TrustyToolsConfig::load();
let managed_root = workspace_root(&config);
self.decommission_with_root(id, &managed_root).await
}
pub(crate) async fn decommission_with_root(
&self,
id: &ManagedSessionId,
managed_root: &Path,
) -> Result<(SessionRecord, bool), ManagedError> {
let mut record = self.get(id).await?;
let search_index_id = search_gc::disposable_workspace_index_id(
record.workspace_path.as_deref(),
record.workspace_owned,
);
self.graceful_terminate_runtime(&record.tmux_name).await;
let mut workspace_removed = false;
if let Some(ref ws) = record.workspace_path {
if !record.workspace_owned {
if is_session_worktree(ws) {
let ws_clone = ws.clone();
let join =
tokio::task::spawn_blocking(move || remove_session_worktree(&ws_clone));
workspace_removed =
match tokio::time::timeout(GIT_WORKTREE_REMOVE_TIMEOUT, join).await {
Ok(Ok(removed)) => removed,
Ok(Err(e)) => {
warn!(
id = %id,
workspace = %ws.display(),
"decommission: remove_session_worktree task panicked: {e}"
);
false
}
Err(_elapsed) => {
warn!(
id = %id,
workspace = %ws.display(),
timeout_secs = GIT_WORKTREE_REMOVE_TIMEOUT.as_secs(),
"decommission: git worktree remove timed out; \
worktree may require manual cleanup"
);
false
}
};
} else {
warn!(
id = %id,
workspace = %ws.display(),
"decommission: skipping workspace removal — not SM-owned \
(local-path or adopted session); the directory was NOT \
created by the session manager"
);
}
} else {
if !ws.exists() {
tracing::debug!(
id = %id,
workspace = %ws.display(),
"decommission: owned workspace already absent — skipping removal"
);
} else {
if !is_safe_to_remove(ws, managed_root) {
warn!(
id = %id,
workspace = %ws.display(),
root = %managed_root.display(),
"decommission: skipping workspace removal — path fails \
containment guard (outside managed root or unsafe path)"
);
} else {
std::fs::remove_dir_all(ws).map_err(|e| {
ManagedError::Io(std::io::Error::new(
e.kind(),
format!("remove workspace {:?}: {e}", ws),
))
})?;
workspace_removed = true;
info!(
id = %id,
workspace = %ws.display(),
"decommission: owned workspace removed from disk"
);
}
}
}
}
if let Some(index_id) = search_index_id {
search_gc::delete_search_index_best_effort(&index_id).await;
}
record.workspace_path = None;
record.workspace_owned = false;
record.state = ManagedSessionState::Decommissioned;
self.store.write().await.upsert(record.clone()).await?;
info!(id = %id, name = %record.tmux_name, "managed session decommissioned");
Ok((record, workspace_removed))
}
pub async fn set_workspace_owned(
&self,
id: &ManagedSessionId,
owned: bool,
) -> Result<(), ManagedError> {
let mut record = self.get(id).await?;
record.workspace_owned = owned;
self.store.write().await.upsert(record).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_session_worktree_detects_dot_worktrees_component() {
assert!(is_session_worktree(std::path::Path::new(
"/home/user/repo/.worktrees/session-abc"
)));
assert!(is_session_worktree(std::path::Path::new(
"/some/base/.worktrees/session-id"
)));
assert!(!is_session_worktree(std::path::Path::new(
"/home/user/repo/session-id"
)));
assert!(!is_session_worktree(std::path::Path::new(
"/base/.worktrees/deep/path"
)));
assert!(!is_session_worktree(std::path::Path::new(
"/base/worktrees/session"
)));
}
#[test]
fn is_session_worktree_absent_path_is_noop() {
let absent = std::path::Path::new("/nonexistent/.worktrees/session-abc");
assert!(is_session_worktree(absent));
let result = remove_session_worktree(absent);
assert!(
result,
"absent path should return true (idempotently removed)"
);
}
#[test]
fn sentinel_gates_worktree_removal_refuses_non_worktrees_dir_without_sentinel() {
let dir = tempfile::tempdir().expect("tempdir");
let wt_path = dir.path().to_path_buf();
assert!(!wt_path.join(WORKTREE_SENTINEL_FILE).exists());
assert!(
!is_session_worktree(&wt_path),
"test invariant: parent must NOT be .worktrees for this branch"
);
let result = remove_session_worktree(&wt_path);
assert!(
!result,
"remove_session_worktree must return false for non-worktrees dir without sentinel"
);
assert!(
wt_path.exists(),
"non-SM directory must remain on disk after refused removal"
);
}
#[test]
fn sentinel_present_passes_safety_gate() {
let dir = tempfile::tempdir().expect("tempdir");
let wt_path = dir.keep();
std::fs::write(wt_path.join(WORKTREE_SENTINEL_FILE), b"").expect("write sentinel");
remove_session_worktree(&wt_path);
assert!(
!wt_path.exists(),
"sentinel present: safety gate must pass and directory must be removed"
);
}
}