use std::path::{Path, PathBuf};
use tracing::warn;
use super::decommission::{
GIT_WORKTREE_REMOVE_TIMEOUT, WORKTREE_SENTINEL_FILE, WorktreeRemoval,
remove_session_worktree_guarded,
};
use super::record::{ManagedSessionId, SessionRecord};
use super::worktree_ownership::SentinelOwner;
use super::worktree_ownership_location::{
OwnerReadError, admin_sentinel_path, legacy_sentinel_path, read_sentinel_owner_strict,
};
use super::worktree_safety::{DirtyWorktreePolicy, inspect_dirt, inspect_dirt_excusing};
pub(crate) const PROVISIONING_FILES: [&str; 4] = [
".gitignore",
".claude/settings.json",
".claude/settings.json.bak",
"CLAUDE.md",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProvisioningDirt {
#[default]
Refuse,
Discard,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DecommissionReport {
pub record: SessionRecord,
pub workspace_removed: bool,
pub workspace_kept_reason: Option<String>,
pub workspace_kept_by_design: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub(super) struct WorkspaceVerdict {
pub removed: bool,
pub kept_reason: Option<String>,
}
const UNTRACKED_PROVISIONING_FILES: [&str; 3] = [
".claude/settings.json",
".claude/settings.json.bak",
"CLAUDE.md",
];
pub(crate) fn is_provisioning_entry(ws: &Path, line: &str) -> bool {
let (Some(status), Some(path)) = (line.get(..3), line.get(3..)) else {
return false;
};
match (status, path.trim()) {
(" M ", ".gitignore") => gitignore_diff_is_provisioning(ws),
("?? ", ".gitignore") => std::fs::read_to_string(ws.join(".gitignore"))
.is_ok_and(|body| body.lines().all(is_provisioning_gitignore_line)),
("?? ", path) => UNTRACKED_PROVISIONING_FILES.contains(&path),
_ => false,
}
}
fn is_provisioning_gitignore_line(line: &str) -> bool {
use crate::core::scaffold_gitignore::{
SCAFFOLD_GITIGNORE_BEGIN, SCAFFOLD_GITIGNORE_END, SCAFFOLD_IGNORED_PATHS,
};
line.trim().is_empty()
|| line == SCAFFOLD_GITIGNORE_BEGIN
|| line == SCAFFOLD_GITIGNORE_END
|| SCAFFOLD_IGNORED_PATHS.contains(&line)
}
fn gitignore_diff_is_provisioning(ws: &Path) -> bool {
gitignore_diff_only_adds(ws, &is_provisioning_gitignore_line)
}
pub(super) fn gitignore_diff_only_adds(ws: &Path, accept: &dyn Fn(&str) -> bool) -> bool {
let args = [
"diff",
"--no-color",
"--no-ext-diff",
"--no-textconv",
"-U0",
"--",
".gitignore",
];
let Ok(diff) = super::worktree_safety::git_stdout(ws, &args) else {
return false;
};
let mut added = 0usize;
let mut in_hunk = false;
for line in diff.lines() {
if !in_hunk {
if line.starts_with("@@ ") {
in_hunk = true;
continue;
}
if line.starts_with("diff --git ")
|| line.starts_with("index ")
|| line.starts_with("--- ")
|| line.starts_with("+++ ")
{
continue;
}
return false;
}
if line.starts_with("@@ ") || line.starts_with("\\ ") {
continue;
}
match line.strip_prefix('+') {
Some(body) if accept(body) => added += 1,
_ => return false,
}
}
added > 0
}
pub(super) async fn remove_in_project_worktree(
id: &ManagedSessionId,
ws: &Path,
policy: ProvisioningDirt,
) -> WorkspaceVerdict {
if std::fs::symlink_metadata(ws).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) {
return WorkspaceVerdict::default();
}
let ws_for_check = ws.to_path_buf();
let owner = *id;
let kept = tokio::task::spawn_blocking(move || keep_reason(&ws_for_check, &owner, policy))
.await
.unwrap_or_else(|e| {
Some(format!("the dirty-tree check panicked: {e}"))
});
if let Some(reason) = kept {
warn!(
id = %id, workspace = %ws.display(), reason = %reason,
"decommission: refusing to remove worktree; leaving it on disk (the record \
is still tombstoned)"
);
return WorkspaceVerdict {
removed: false,
kept_reason: Some(reason),
};
}
let ws_clone = ws.to_path_buf();
let join = tokio::task::spawn_blocking(move || {
let guard = || match policy {
ProvisioningDirt::Refuse => None,
ProvisioningDirt::Discard => keep_reason(&ws_clone, &owner, policy),
};
remove_session_worktree_guarded(
&ws_clone,
"session decommission: the session ended and its tree is clean",
&guard,
DirtyWorktreePolicy::Skip,
)
});
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()
)),
};
if let Some(reason) = outcome.reason() {
warn!(id = %id, workspace = %ws.display(), "decommission: worktree left on disk — {reason}");
}
WorkspaceVerdict {
removed: outcome.removed(),
kept_reason: outcome.reason().map(str::to_string),
}
}
fn keep_reason(ws: &Path, id: &ManagedSessionId, policy: ProvisioningDirt) -> Option<String> {
let plain = inspect_dirt(ws)?;
if policy == ProvisioningDirt::Refuse {
return Some(kept_for_dirt(ws, &plain.reason, policy));
}
if let Some(blocker) = force_blocker(ws, id) {
return Some(format!("--force declined: {blocker}; nothing was removed"));
}
inspect_dirt_excusing(ws, &|line| is_provisioning_entry(ws, line))
.map(|d| kept_for_dirt(ws, &d.reason, policy))
}
const CONTENT_UNCHECKED_FILES: [&str; 2] = [".claude/settings.json", "CLAUDE.md"];
pub(super) fn kept_for_dirt(ws: &Path, reason: &str, policy: ProvisioningDirt) -> String {
let files = PROVISIONING_FILES.join(", ");
let entries = dirty_entries(ws);
let named = blocking_entries(ws, &entries, policy);
match policy {
ProvisioningDirt::Refuse => format!(
"the dirty-tree guard kept it ({reason}{named}). If the only changes are tm's own \
provisioning files ({files}), re-run with --force to remove it; --force never \
discards other changes or unpushed commits{}",
force_discard_warning(&entries)
),
ProvisioningDirt::Discard => format!(
"--force excused tm's provisioning files ({files}), but the dirty-tree guard \
still kept it ({reason}{named}); --force never discards that"
),
}
}
const NAMED_ENTRIES_CAP: usize = 10;
fn blocking_entries(ws: &Path, entries: &[String], policy: ProvisioningDirt) -> String {
let entries: Vec<&str> = entries
.iter()
.map(String::as_str)
.filter(|line| policy == ProvisioningDirt::Refuse || !is_provisioning_entry(ws, line))
.collect();
if entries.is_empty() {
return String::new();
}
let mut named = entries
.iter()
.take(NAMED_ENTRIES_CAP)
.map(|l| l.trim())
.collect::<Vec<_>>()
.join(", ");
if entries.len() > NAMED_ENTRIES_CAP {
named.push_str(&format!(", and {} more", entries.len() - NAMED_ENTRIES_CAP));
}
format!(": {named}")
}
pub(super) fn dirty_entries(ws: &Path) -> Vec<String> {
let args = [
"status",
"--porcelain",
"--untracked-files=all",
"--ignore-submodules=none",
];
let Ok(status) = super::worktree_safety::git_stdout(ws, &args) else {
return Vec::new();
};
status
.lines()
.filter(|line| !line.trim().is_empty())
.filter(|line| {
let path = line.get(3..).unwrap_or("").trim();
path != WORKTREE_SENTINEL_FILE
&& path != ".trusty-mpm"
&& !path.starts_with(".trusty-mpm/")
})
.map(str::to_string)
.collect()
}
fn force_discard_warning(entries: &[String]) -> String {
let named: Vec<&str> = CONTENT_UNCHECKED_FILES
.into_iter()
.filter(|file| entries.iter().any(|line| line == &format!("?? {file}")))
.collect();
let (list, pronoun) = match named.as_slice() {
[] => return String::new(),
[one] => ((*one).to_string(), "it"),
[init @ .., last] => (format!("{} and {last}", init.join(", ")), "them"),
};
format!(
". WARNING: --force deletes the untracked {list} with the worktree, including any edits \
made to {pronoun}; copy out anything you added there first"
)
}
pub(crate) fn force_blocker(ws: &Path, id: &ManagedSessionId) -> Option<String> {
if let Some(reason) = ownership_blocker(ws, id) {
return Some(reason);
}
match linked_worktree_git_dir(ws) {
Ok(git_dir) => lock_blocker(&git_dir),
Err(reason) => Some(reason),
}
}
fn ownership_blocker(ws: &Path, id: &ManagedSessionId) -> Option<String> {
let unproven = |reason: String| Some(format!("cannot prove tm created it: {reason}"));
for marker in std::iter::once(legacy_sentinel_path(ws)).chain(admin_sentinel_path(ws)) {
if std::fs::symlink_metadata(&marker).is_ok_and(|meta| !meta.is_file()) {
return unproven(format!(
"its ownership sentinel {} is not a regular file",
marker.display()
));
}
}
match read_sentinel_owner_strict(ws) {
Ok(Some(SentinelOwner::Unknown)) => None,
Ok(Some(SentinelOwner::Known(owner, _))) if owner == *id => None,
Ok(Some(SentinelOwner::Known(owner, _))) => Some(format!(
"its ownership sentinel names session {owner}, not {id}; --force never removes \
another session's worktree"
)),
Ok(Some(SentinelOwner::Agent(agent, _))) => Some(format!(
"its ownership sentinel names agent {}, not session {id}; --force never removes \
an agent's worktree",
agent.agent_id
)),
Ok(None) => unproven(format!(
"it carries no ownership sentinel (neither in its git admin dir nor as \
{WORKTREE_SENTINEL_FILE})"
)),
Err(OwnerReadError::Unreadable { path, reason }) => unproven(format!(
"its ownership sentinel could not be read ({}): {reason}",
path.display()
)),
Err(OwnerReadError::Corrupt { path, reason }) => unproven(format!(
"its ownership sentinel {} is corrupt: {reason}",
path.display()
)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum WorktreeKind {
Linked(PathBuf),
MainCheckout,
}
pub(super) fn linked_worktree_git_dir(ws: &Path) -> Result<PathBuf, String> {
match worktree_kind(ws)? {
WorktreeKind::Linked(git_dir) => Ok(git_dir),
WorktreeKind::MainCheckout => Err(
"it is a repository's main checkout, not a linked worktree tm created; a main \
checkout is never removed"
.to_string(),
),
}
}
pub(super) fn worktree_kind(ws: &Path) -> Result<WorktreeKind, String> {
let args = [
"rev-parse",
"--path-format=absolute",
"--show-toplevel",
"--git-dir",
"--git-common-dir",
];
let out = super::worktree_safety::git_stdout(ws, &args)
.map_err(|e| format!("cannot prove it is a tm-created linked worktree: {e}"))?;
let lines: Vec<&str> = out.lines().collect();
let [top, git_dir, common] = lines.as_slice() else {
return Err(format!(
"cannot prove it is a tm-created linked worktree: unexpected `git rev-parse` \
output {out:?}"
));
};
let resolve = |p: &Path| {
std::fs::canonicalize(p).map_err(|e| {
format!(
"cannot prove it is a tm-created linked worktree: {} could not be resolved: {e}",
p.display()
)
})
};
if resolve(Path::new(top))? != resolve(ws)? {
return Err(format!(
"it is not a worktree root (git reports the root as {top})"
));
}
let git_dir = resolve(Path::new(git_dir))?;
if git_dir == resolve(Path::new(common))? {
return Ok(WorktreeKind::MainCheckout);
}
Ok(WorktreeKind::Linked(git_dir))
}
pub(crate) fn lock_blocker(git_dir: &Path) -> Option<String> {
let lock = git_dir.join("locked");
match std::fs::symlink_metadata(&lock) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Ok(_) => {
let why = std::fs::read_to_string(&lock).unwrap_or_default();
let why = why.trim();
Some(if why.is_empty() {
"it is locked (`git worktree lock`)".to_string()
} else {
format!("it is locked (`git worktree lock`: {why})")
})
}
Err(e) => Some(format!(
"its lock state could not be read ({}): {e}",
lock.display()
)),
}
}
pub(crate) fn unowned_kept_reason(ws: &Path, policy: ProvisioningDirt) -> Option<String> {
if std::fs::symlink_metadata(ws).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) {
return None;
}
let kind = match std::fs::symlink_metadata(ws.join(".git")) {
Ok(meta) if meta.is_dir() => {
"a repository's main checkout, shared by the project's sessions"
}
Ok(_) => "a git worktree tm did not create for this session",
Err(_) => "a local-path or adopted directory tm did not create",
};
let force = match policy {
ProvisioningDirt::Refuse => "",
ProvisioningDirt::Discard => "; --force does not apply to it",
};
Some(format!(
"kept by design: {} is {kind}, and decommission never deletes it{force}",
ws.display()
))
}
#[cfg(test)]
#[path = "decommission_force_tests.rs"]
mod decommission_force_tests;