use std::path::Path;
use tracing::warn;
use super::decommission::{
GIT_WORKTREE_REMOVE_TIMEOUT, WorktreeRemoval, remove_session_worktree_guarded,
};
use super::record::{ManagedSessionId, SessionRecord};
use super::worktree_safety::{DirtyWorktree, 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)]
pub struct DecommissionReport {
pub record: SessionRecord,
pub workspace_removed: bool,
pub workspace_kept_reason: 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 {
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 is_provisioning_gitignore_line(body) => added += 1,
_ => return false,
}
}
added > 0
}
pub(super) async fn remove_in_project_worktree(
id: &ManagedSessionId,
ws: &Path,
policy: ProvisioningDirt,
) -> WorkspaceVerdict {
let ws_for_check = ws.to_path_buf();
let dirt = tokio::task::spawn_blocking(move || dirt_under(&ws_for_check, policy))
.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 is still tombstoned)"
);
return WorkspaceVerdict {
removed: false,
kept_reason: Some(kept_for_dirt(&dirt.reason, policy)),
};
}
let ws_clone = ws.to_path_buf();
let join = tokio::task::spawn_blocking(move || {
let guard = || match policy {
ProvisioningDirt::Refuse => None,
ProvisioningDirt::Discard => dirt_under(&ws_clone, policy).map(|d| d.reason),
};
remove_session_worktree_guarded(
&ws_clone,
"session decommission: the session ended and its tree is clean",
&guard,
)
});
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 dirt_under(ws: &Path, policy: ProvisioningDirt) -> Option<DirtyWorktree> {
match policy {
ProvisioningDirt::Refuse => inspect_dirt(ws),
ProvisioningDirt::Discard => {
inspect_dirt_excusing(ws, &|line| is_provisioning_entry(ws, line))
}
}
}
fn kept_for_dirt(reason: &str, policy: ProvisioningDirt) -> String {
let files = PROVISIONING_FILES.join(", ");
match policy {
ProvisioningDirt::Refuse => format!(
"the dirty-tree guard kept it ({reason}). 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}); --force never discards that"
),
}
}
#[cfg(test)]
#[path = "decommission_force_tests.rs"]
mod decommission_force_tests;