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;
use super::worktree_safety::{DirtyWorktree, inspect_dirt};
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 super::worktree_registry::registry_root_for(path) {
Some(r) => r,
None => {
warn!(
path = %path.display(),
"decommission: no git repository owns this path — removing the \
directory directly (no worktree ref to prune)"
);
if let Err(e) = std::fs::remove_dir_all(path) {
warn!(path = %path.display(), "decommission: remove_dir_all failed: {e}");
return false;
}
return true;
}
};
let repo_root = repo_root.as_path();
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, false)
.await
}
pub async fn decommission_record_only(
&self,
id: &ManagedSessionId,
) -> Result<(SessionRecord, bool), ManagedError> {
let config = TrustyToolsConfig::load();
let managed_root = workspace_root(&config);
self.decommission_with_root(id, &managed_root, None, true)
.await
}
pub(crate) async fn decommission_with_root(
&self,
id: &ManagedSessionId,
managed_root: &Path,
caller: Option<ManagedSessionId>,
record_only: bool,
) -> 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 !record_only && let Some(ref ws) = record.workspace_path {
if !record.workspace_owned {
if is_session_worktree(ws) {
let ws_for_check = ws.clone();
let dirt = tokio::task::spawn_blocking(move || inspect_dirt(&ws_for_check))
.await
.unwrap_or_else(|e| {
Some(DirtyWorktree::new(
ws,
format!("dirty-check task panicked: {e}"),
0,
0,
))
});
if let Some(dirt) = dirt {
warn!(
id = %id,
workspace = %ws.display(),
reason = %dirt.reason,
"decommission: refusing to remove worktree — it holds \
unsaved work; leaving it on disk (the record below is \
still tombstoned)"
);
} else {
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;
}
let workspace_still_on_disk = record.workspace_path.as_deref().is_some_and(|p| p.exists());
if !workspace_still_on_disk {
record.workspace_path = None;
record.workspace_owned = false;
}
record.pending_decision = None;
record.proposed_default = None;
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(())
}
}