use std::path::Path;
use chrono::Utc;
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_protection;
use super::worktree_safety::{DirtyWorktree, inspect_dirt};
pub(crate) const WORKTREE_SENTINEL_FILE: &str = ".trusty-mpm-worktree";
pub(crate) fn worktrees_dirname() -> String {
crate::core::trusty_tools_config::worktrees_dirname(
&crate::core::trusty_tools_config::TrustyToolsConfig::load(),
)
}
pub(crate) fn worktree_dir_names() -> trusty_common::workspace_layout::WorktreeDirNames {
trusty_common::workspace_layout::WorktreeDirNames::from_configured(
crate::core::trusty_tools_config::TrustyToolsConfig::load()
.worktrees_dirname
.as_deref(),
)
}
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 {
is_session_worktree_with(path, &worktree_dir_names())
}
pub(crate) fn is_session_worktree_with(
path: &Path,
names: &trusty_common::workspace_layout::WorktreeDirNames,
) -> bool {
path.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|n| names.matches(n))
.unwrap_or(false)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum WorktreeRemoval {
Removed,
Kept(String),
}
impl WorktreeRemoval {
pub(super) fn removed(&self) -> bool {
matches!(self, Self::Removed)
}
pub(super) fn reason(&self) -> Option<&str> {
match self {
Self::Removed => None,
Self::Kept(reason) => Some(reason),
}
}
}
pub(super) fn remove_session_worktree(path: &Path) -> WorktreeRemoval {
if !path.exists() {
return WorktreeRemoval::Removed;
}
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 WorktreeRemoval::Kept(format!(
"no trusty-mpm ownership sentinel ({WORKTREE_SENTINEL_FILE}) and the path \
is not under {}/",
worktrees_dirname()
));
}
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 => {
let verdict = worktree_protection::protection_without_registry_root(path);
if let Some(reason) = verdict.refusal() {
warn!(
path = %path.display(),
"decommission: refusing worktree removal — {reason} (#4732)"
);
return WorktreeRemoval::Kept(reason.to_string());
}
warn!(
path = %path.display(),
"decommission: no git repository claims this path — removing the \
directory directly (no worktree ref to prune)"
);
return remove_unclaimed_directory(path);
}
};
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();
match out {
Ok(o) if o.status.success() => {
info!(path = %path.display(), "decommission: git worktree removed (incl. ref)");
prune_refs_after_removal(repo_root, path);
WorktreeRemoval::Removed
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
let verdict =
worktree_protection::protection_after_failed_removal(path, repo_root, &stderr);
if let Some(reason) = verdict.refusal() {
warn!(
path = %path.display(),
"decommission: git worktree remove --force declined ({}) and the \
directory must be preserved — {reason} (#4732)",
o.status
);
return WorktreeRemoval::Kept(reason.to_string());
}
warn!(
path = %path.display(),
"decommission: git holds no worktree state at this path ({}: {stderr}); \
removing the leftover directory directly",
o.status
);
remove_unclaimed_directory(path)
}
Err(e) => {
let reason = format!("git could not be run to remove the worktree: {e}");
warn!(
path = %path.display(),
"decommission: refusing worktree removal — {reason} (#4732)"
);
WorktreeRemoval::Kept(reason)
}
}
}
fn remove_unclaimed_directory(path: &Path) -> WorktreeRemoval {
match std::fs::remove_dir_all(path) {
Ok(()) => WorktreeRemoval::Removed,
Err(e) => {
warn!(path = %path.display(), "decommission: remove_dir_all failed: {e}");
WorktreeRemoval::Kept(format!("removing the directory failed: {e}"))
}
}
}
fn prune_refs_after_removal(repo_root: &Path, path: &Path) {
{
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}"
);
}
}
}
}
}
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 async fn decommission_record_only(
&self,
id: &ManagedSessionId,
) -> Result<(SessionRecord, bool), ManagedError> {
let mut record = self.get(id).await?;
self.check_worktree_owner(&record, None, 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.set_lifecycle_state(ManagedSessionState::Decommissioned, Utc::now());
self.store.write().await.upsert(record.clone()).await?;
info!(id = %id, name = %record.tmux_name, "managed session record tombstoned (record-only)");
Ok((record, false))
}
async fn check_worktree_owner(
&self,
record: &SessionRecord,
caller: Option<ManagedSessionId>,
id: &ManagedSessionId,
) -> Result<(), ManagedError> {
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));
}
Ok(())
}
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?;
self.check_worktree_owner(&record, caller, 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_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));
let outcome =
match tokio::time::timeout(GIT_WORKTREE_REMOVE_TIMEOUT, join).await {
Ok(Ok(outcome)) => outcome,
Ok(Err(e)) => {
WorktreeRemoval::Kept(format!("the removal task panicked: {e}"))
}
Err(_elapsed) => WorktreeRemoval::Kept(format!(
"git worktree remove did not finish within {}s; the \
worktree may require manual cleanup",
GIT_WORKTREE_REMOVE_TIMEOUT.as_secs()
)),
};
workspace_removed = outcome.removed();
if let Some(reason) = outcome.reason() {
warn!(
id = %id,
workspace = %ws.display(),
"decommission: worktree left on disk — {reason}"
);
}
}
} 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.set_lifecycle_state(ManagedSessionState::Decommissioned, Utc::now());
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(())
}
}