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(crate) 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,
caller: Option<ManagedSessionId>,
) -> Result<(SessionRecord, bool), ManagedError> {
let config = TrustyToolsConfig::load();
let managed_root = workspace_root(&config);
self.decommission_with_root(id, &managed_root, caller).await
}
pub(crate) async fn decommission_with_root(
&self,
id: &ManagedSessionId,
managed_root: &Path,
caller: Option<ManagedSessionId>,
) -> Result<(SessionRecord, bool), ManagedError> {
let mut record = self.get(id).await?;
if let Some(caller_id) = caller
&& let Some(owner) = self.known_owner_of(&record)
&& owner != caller_id
&& !self.resolve_ownerless(owner).await
{
return Err(ManagedError::WorktreeOwnerMismatch(caller_id, owner, *id));
}
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"
);
}
fn owned_record(id: ManagedSessionId, state: ManagedSessionState) -> SessionRecord {
SessionRecord {
id,
tmux_name: format!("tm-owner-gate-{id}"),
cwd: std::path::PathBuf::from("/tmp"),
task: "task".into(),
state,
created_at: chrono::Utc::now(),
last_activity_at: None,
workspace_path: None,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
injection_status: Default::default(),
worktree_owner: Some(id),
}
}
#[tokio::test]
async fn decommission_owner_gate_refuses_foreign_caller() {
let dir = crate::test_support::hermetic_temp_dir();
let mgr = SessionManager::new(
dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let session_b = ManagedSessionId::new();
mgr.store
.write()
.await
.upsert(owned_record(session_b, ManagedSessionState::Active))
.await
.expect("upsert B");
let session_a = ManagedSessionId::new();
let managed_root = crate::test_support::hermetic_temp_dir();
let err = mgr
.decommission_with_root(&session_b, managed_root.path(), Some(session_a))
.await
.expect_err("session A must be refused decommissioning session B's live worktree");
match err {
ManagedError::WorktreeOwnerMismatch(caller, owner, target) => {
assert_eq!(caller, session_a);
assert_eq!(owner, session_b);
assert_eq!(target, session_b);
}
other => panic!("expected WorktreeOwnerMismatch, got {other:?}"),
}
let record = mgr.get(&session_b).await.expect("get B");
assert_eq!(record.state, ManagedSessionState::Active);
}
#[tokio::test]
async fn decommission_owner_gate_allows_self() {
let dir = crate::test_support::hermetic_temp_dir();
let mgr = SessionManager::new(
dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let session_id = ManagedSessionId::new();
mgr.store
.write()
.await
.upsert(owned_record(session_id, ManagedSessionState::Active))
.await
.expect("upsert");
let managed_root = crate::test_support::hermetic_temp_dir();
let (record, _workspace_removed) = mgr
.decommission_with_root(&session_id, managed_root.path(), Some(session_id))
.await
.expect("self-decommission must be allowed");
assert_eq!(record.state, ManagedSessionState::Decommissioned);
}
#[tokio::test]
async fn decommission_owner_gate_allows_terminal_owner() {
let dir = crate::test_support::hermetic_temp_dir();
let mgr = SessionManager::new(
dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let target = ManagedSessionId::new();
mgr.store
.write()
.await
.upsert(owned_record(target, ManagedSessionState::Decommissioned))
.await
.expect("upsert target");
let caller = ManagedSessionId::new();
let managed_root = crate::test_support::hermetic_temp_dir();
mgr.decommission_with_root(&target, managed_root.path(), Some(caller))
.await
.expect(
"decommissioning an already-terminal, provably-ownerless target must be allowed",
);
}
#[tokio::test]
async fn decommission_owner_gate_bypassed_for_none_caller() {
let dir = crate::test_support::hermetic_temp_dir();
let mgr = SessionManager::new(
dir.path(),
crate::session_manager::tests::FakeTmuxDriver::new(),
)
.await
.expect("manager");
let session_id = ManagedSessionId::new();
mgr.store
.write()
.await
.upsert(owned_record(session_id, ManagedSessionState::Active))
.await
.expect("upsert");
let managed_root = crate::test_support::hermetic_temp_dir();
let (record, _workspace_removed) = mgr
.decommission_with_root(&session_id, managed_root.path(), None)
.await
.expect("operator (caller=None) decommission must never be gated");
assert_eq!(record.state, ManagedSessionState::Decommissioned);
}
}