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::{foreign_active_claim, is_safe_to_remove};
use super::worktree_protection;
use super::worktree_registry;
use super::worktree_safety::{DirtyWorktree, inspect_dirt, worktree_remove_command};
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)
}
pub(crate) fn removal_permitted(path: &Path) -> bool {
path.join(WORKTREE_SENTINEL_FILE).exists()
|| is_session_worktree(path)
|| super::worktree_ownership::is_harness_agent_worktree(path)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) 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, reason: &str) -> WorktreeRemoval {
remove_session_worktree_guarded(path, reason, &|| None)
}
pub(super) fn remove_session_worktree_guarded(
path: &Path,
reason: &str,
guard: &dyn Fn() -> Option<String>,
) -> WorktreeRemoval {
if !path.exists() {
return WorktreeRemoval::Removed;
}
let sentinel = path.join(WORKTREE_SENTINEL_FILE);
if !sentinel.exists() {
if !removal_permitted(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 {}/ or the harness agent-worktree store",
worktrees_dirname()
));
}
warn!(
path = %path.display(),
sentinel = WORKTREE_SENTINEL_FILE,
"decommission: sentinel absent; falling back to convention check \
(backward-compat with pre-sentinel worktrees)"
);
}
super::worktree_removal_audit::audited_removal(path, reason, || {
if let Some(refusal) = guard() {
return WorktreeRemoval::Kept(format!("refused immediately before removal: {refusal}"));
}
remove_registered_worktree(path)
})
}
fn remove_registered_worktree(path: &Path) -> WorktreeRemoval {
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 = worktree_remove_command(repo_root, 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 = trusty_common::git::command_in(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}"
);
}
}
}
}
}
pub(super) fn registry_root_to_repair(record: &SessionRecord) -> Option<std::path::PathBuf> {
if !record.workspace_owned {
return None;
}
let ws = record.workspace_path.as_deref()?;
let root = worktree_registry::registry_root_for(ws)?;
let ws_resolved = std::fs::canonicalize(ws).unwrap_or_else(|_| ws.to_path_buf());
(root != ws_resolved).then_some(root)
}
fn prune_worktree_registry(root: &Path) {
match trusty_common::git::command_in(root)
.args(["worktree", "prune"])
.output()
{
Ok(out) if out.status.success() => {
info!(root = %root.display(), "decommission: worktree registry pruned");
}
Ok(out) => warn!(
root = %root.display(),
"decommission: worktree prune exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
),
Err(e) => warn!(
root = %root.display(),
"decommission: worktree prune 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?;
self.bump_residency_generation();
self.residency_cache_evict(id).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(())
}
async fn check_no_foreign_active_claim(
&self,
record: &SessionRecord,
) -> Result<(), ManagedError> {
let Some(ws) = record.workspace_path.as_deref() else {
return Ok(());
};
let records = self.list().await;
if let Some(other) = foreign_active_claim(ws, &record.id, &records) {
warn!(
id = %record.id,
other = %other,
workspace = %ws.display(),
"decommission: refusing — an Active sibling session also claims this \
workspace/cwd (#3764, #1744)"
);
return Err(ManagedError::ForeignActiveWorkspaceClaim(record.id, other));
}
Ok(())
}
pub(crate) async fn decommission_with_root(
&self,
id: &ManagedSessionId,
managed_root: &Path,
caller: Option<ManagedSessionId>,
) -> Result<(SessionRecord, bool), ManagedError> {
self.decommission_with_root_checked(id, managed_root, caller, true)
.await
}
pub(crate) async fn decommission_dedup_loser(
&self,
id: &ManagedSessionId,
) -> Result<(SessionRecord, bool), ManagedError> {
let config = TrustyToolsConfig::load();
let managed_root = workspace_root(&config);
self.decommission_with_root_checked(id, &managed_root, None, false)
.await
}
async fn decommission_with_root_checked(
&self,
id: &ManagedSessionId,
managed_root: &Path,
caller: Option<ManagedSessionId>,
check_foreign_claim: bool,
) -> Result<(SessionRecord, bool), ManagedError> {
let mut record = self.get(id).await?;
self.check_worktree_owner(&record, caller, id).await?;
if check_foreign_claim {
self.check_no_foreign_active_claim(&record).await?;
}
let search_index_id = search_gc::disposable_workspace_index_id(
record.workspace_path.as_deref(),
record.workspace_owned,
);
let registry_root = registry_root_to_repair(&record);
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,
"session decommission: the session ended and its tree is clean",
)
});
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 {
let mut failure: Option<std::io::Error> = None;
super::worktree_removal_audit::audited_removal(
ws,
"session decommission: owned workspace, containment guard passed",
|| match std::fs::remove_dir_all(ws) {
Ok(()) => WorktreeRemoval::Removed,
Err(e) => {
let kept = WorktreeRemoval::Kept(format!(
"removing the workspace failed: {e}"
));
failure = Some(e);
kept
}
},
);
if let Some(e) = failure {
return Err(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(ref root) = registry_root {
prune_worktree_registry(root);
}
}
}
}
}
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?;
self.bump_residency_generation();
self.residency_cache_evict(id).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(())
}
}