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::provisioning_ledger;
use super::record::{ManagedSessionId, SessionRecord};
use super::worktree_liveness::process_holding;
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, ExcuseEntry, inspect_dirt_excusing};
use crate::core::standalone::hooks::backup;
pub(crate) const PROVISIONING_FILES: [&str; 6] = [
".gitignore",
"unedited .claude/settings.json",
"unedited .claude/settings.json.bak",
".claude/settings.json.<timestamp>.bak",
"unedited CLAUDE.md",
"unedited TASK.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 LEDGER_CHECKED_FILES: [&str; 3] = [
".claude/settings.json",
".claude/settings.json.bak",
"CLAUDE.md",
];
pub(crate) fn is_provisioning_entry(ws: &Path, task: Option<&str>, 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") => provisioning_ledger::read_regular_file(&ws.join(".gitignore"))
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.is_some_and(|body| body.lines().all(is_provisioning_gitignore_line)),
("?? ", TASK_MD) => task_md_is_unedited(ws, task),
("?? ", path) if LEDGER_CHECKED_FILES.contains(&path) => is_ledgered_write(ws, line),
("?? ", path) => is_settings_snapshot(path),
_ => false,
}
}
fn is_ledgered_write(ws: &Path, line: &str) -> bool {
provisioning_ledger::load(ws).is_some_and(|ledger| ledger.excuses(ws, line))
}
const TASK_MD: &str = "TASK.md";
fn task_md_is_unedited(ws: &Path, task: Option<&str>) -> bool {
use std::io::Read;
let Some(task) = task else {
return false;
};
let path = ws.join(TASK_MD);
let Ok(meta) = std::fs::symlink_metadata(&path) else {
return false;
};
let Some(want) = [Some(task), task_before_single_error_note(task)]
.into_iter()
.flatten()
.find(|c| !c.is_empty() && meta.is_file() && meta.len() == c.len() as u64)
else {
return false;
};
let mut bytes = Vec::with_capacity(want.len());
std::fs::File::open(&path)
.and_then(|file| file.take(meta.len() + 1).read_to_end(&mut bytes))
.is_ok()
&& bytes == want.as_bytes()
}
const ERROR_NOTE_OPEN: &str = " [error: ";
fn task_before_single_error_note(task: &str) -> Option<&str> {
let mut markers = task.char_indices().map(|(pos, _)| pos).filter(|&pos| {
task.get(pos..)
.is_some_and(|s| s.starts_with(ERROR_NOTE_OPEN))
});
match (markers.next(), markers.next()) {
(Some(pos), None) if task.ends_with(']') => task.get(..pos),
_ => None,
}
}
fn is_settings_snapshot(path: &str) -> bool {
path.strip_prefix(".claude/")
.is_some_and(|name| backup::is_snapshot_of("settings.json", name))
}
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,
task: Option<&str>,
ws: &Path,
policy: ProvisioningDirt,
) -> WorkspaceVerdict {
remove_in_project_worktree_with(id, task, ws, policy, process_holding).await
}
pub(super) async fn remove_in_project_worktree_with(
id: &ManagedSessionId,
task: Option<&str>,
ws: &Path,
policy: ProvisioningDirt,
cwd_holder: fn(&Path) -> Option<String>,
) -> 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 task_for_check = task.map(str::to_owned);
let task_for_guard = task_for_check.clone();
let kept = tokio::task::spawn_blocking(move || {
keep_reason(&ws_for_check, &owner, task_for_check.as_deref(), 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, task_for_guard.as_deref(), policy)
}
}
.or_else(|| cwd_holder(&ws_clone))
};
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,
task: Option<&str>,
policy: ProvisioningDirt,
) -> Option<String> {
let nothing = |_: &str| false;
let plain = inspect_dirt_excusing(ws, ¬hing)?;
if policy == ProvisioningDirt::Refuse {
return Some(kept_for_dirt(ws, &plain.reason, policy, ¬hing));
}
if let Some(blocker) = force_blocker(ws, id) {
return Some(format!("--force declined: {blocker}; nothing was removed"));
}
let excuse = |line: &str| is_provisioning_entry(ws, task, line);
inspect_dirt_excusing(ws, &excuse).map(|d| kept_for_dirt(ws, &d.reason, policy, &excuse))
}
pub(super) fn kept_for_dirt(
ws: &Path,
reason: &str,
policy: ProvisioningDirt,
excuse: ExcuseEntry,
) -> String {
let files = PROVISIONING_FILES.join(", ");
let entries = dirty_entries(ws);
let blocking: Vec<&str> = entries
.iter()
.map(String::as_str)
.filter(|line| !excuse(line))
.collect();
let named = name_entries(&blocking);
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"
),
ProvisioningDirt::Discard => format!(
"--force excused tm's provisioning files ({files}), but the dirty-tree guard \
still kept it ({reason}{named}); --force never discards that{}",
ledger_kept_note(&blocking)
),
}
}
fn ledger_kept_note(blocking: &[&str]) -> String {
let named: Vec<&str> = LEDGER_CHECKED_FILES
.into_iter()
.filter(|file| blocking.iter().any(|line| *line == format!("?? {file}")))
.collect();
let (list, is, differs) = match named.as_slice() {
[] => return String::new(),
[one] => ((*one).to_string(), "is", "differs"),
[init @ .., last] => (format!("{} and {last}", init.join(", ")), "are", "differ"),
};
format!(
". {list} {is} not recorded in tm's provisioning ledger, or {differs} from what it \
recorded, or the ledger could not be read, so --force kept the worktree and deleted \
nothing"
)
}
const NAMED_ENTRIES_CAP: usize = 10;
fn name_entries(blocking: &[&str]) -> String {
if blocking.is_empty() {
return String::new();
}
let mut named = blocking
.iter()
.take(NAMED_ENTRIES_CAP)
.map(|l| l.trim())
.collect::<Vec<_>>()
.join(", ");
if blocking.len() > NAMED_ENTRIES_CAP {
named.push_str(&format!(
", and {} more",
blocking.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()
}
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;