use std::io::Read;
use std::path::Path;
use tracing::warn;
use super::worktree_deployed_assets::{DeployedAssets, is_asset_path};
use super::worktree_nested::is_disposable_dir_name;
use super::worktree_safety::{DirtyWorktree, DirtyWorktreePolicy, git_stdout, inspect_dirt};
use crate::core::scaffold_gitignore::SCAFFOLD_IGNORED_PATHS;
use crate::core::standalone::hooks::backup;
const DISPOSABLE_DIR_PATHS: &[&str] = &["src-tauri/gen"];
const REGENERABLE_FILES: &[&str] = &[".DS_Store", "Thumbs.db", ".eslintcache", ".coverage"];
const REGENERABLE_SUFFIXES: &[&str] = &[".pyc", ".tsbuildinfo"];
const HARNESS_PATHS: &[&str] = &[
".trusty-mpm/",
".claude/skills/.trusty-mpm-skills-manifest.json*",
".claude/skills/.trusty-mpm-project-tier-stamp",
".claude/agents/.trusty-mpm-manifest.json*",
];
const CACHEDIR_TAG_SIGNATURE: &[u8] = b"Signature: 8a477f597d28d172789f06886806bc55";
const IGNORED_STATUS_ARGS: &[&str] = &[
"status",
"--porcelain",
"-z",
"--ignored=matching",
"--untracked-files=normal",
"--ignore-submodules=none",
];
const FILE_COUNT_CAP: usize = 100_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct IgnoredOutput {
pub files: usize,
pub first: String,
pub entries: Vec<(String, usize)>,
}
pub(crate) fn is_regenerable(entry: &str) -> bool {
let entry = entry.trim_end_matches('/');
let basename = entry.rsplit('/').next().unwrap_or(entry);
is_disposable_dir_name(basename)
|| DISPOSABLE_DIR_PATHS
.iter()
.any(|p| entry == *p || entry.ends_with(&format!("/{p}")))
|| REGENERABLE_FILES.contains(&basename)
|| REGENERABLE_SUFFIXES.iter().any(|s| basename.ends_with(s))
}
pub(crate) fn is_harness_path(rel: &str) -> bool {
let rel = rel.trim_end_matches('/');
rel == super::decommission::WORKTREE_SENTINEL_FILE
|| is_settings_harness(rel)
|| HARNESS_PATHS
.iter()
.chain(SCAFFOLD_IGNORED_PATHS.iter().filter(|p| !is_asset_path(p)))
.any(|pattern| path_matches(pattern, rel))
}
const SETTINGS_FILES: [&str; 2] = ["settings.json", "settings.local.json"];
fn is_settings_harness(rel: &str) -> bool {
let Some(name) = rel.strip_prefix(".claude/") else {
return false;
};
SETTINGS_FILES.iter().any(|base| {
name.strip_prefix(base)
.is_some_and(|rest| rest.is_empty() || rest == ".bak" || is_staging_suffix(rest))
|| backup::is_snapshot_of(base, name)
})
}
fn is_staging_suffix(rest: &str) -> bool {
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
let pid_seq = |s: &str| {
s.split_once('.')
.is_some_and(|(p, q)| digits(p) && digits(q))
};
rest.strip_prefix(".tmp.")
.or_else(|| rest.strip_prefix(".bak."))
.is_some_and(pid_seq)
|| rest
.strip_prefix('.')
.and_then(|r| r.strip_suffix(".tmp"))
.is_some_and(pid_seq)
}
fn path_matches(pattern: &str, rel: &str) -> bool {
let pattern = pattern.trim_end_matches('/');
match pattern.split_once('*') {
None => rel
.strip_prefix(pattern)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/')),
Some((head, tail)) => rel.strip_prefix(head).is_some_and(|rest| {
let segment = rest.split('/').next().unwrap_or(rest);
segment.ends_with(tail)
}),
}
}
fn under_cache_dir(root: &Path, rel: &str) -> bool {
let mut prefix = root.to_path_buf();
rel.split('/').filter(|p| !p.is_empty()).any(|part| {
prefix.push(part);
is_tagged_cache_dir(&prefix)
})
}
fn is_tagged_cache_dir(dir: &Path) -> bool {
let tag = dir.join("CACHEDIR.TAG");
if !std::fs::symlink_metadata(&tag).is_ok_and(|m| m.is_file()) {
return false;
}
let mut head = Vec::with_capacity(CACHEDIR_TAG_SIGNATURE.len());
std::fs::File::open(&tag)
.and_then(|f| {
f.take(CACHEDIR_TAG_SIGNATURE.len() as u64)
.read_to_end(&mut head)
})
.is_ok_and(|_| head == CACHEDIR_TAG_SIGNATURE)
}
pub(crate) fn kept_ignored_output(path: &Path) -> Result<Option<IgnoredOutput>, String> {
let status = git_stdout(path, IGNORED_STATUS_ARGS)?;
kept_among(path, ignored_entries(&status))
}
pub(crate) fn kept_unversioned_content(path: &Path) -> Result<Option<IgnoredOutput>, String> {
let unreadable = |e: std::io::Error| format!("`{}` is unreadable: {e}", path.display());
let mut names = Vec::new();
for entry in std::fs::read_dir(path).map_err(unreadable)? {
names.push(entry.map_err(unreadable)?.file_name());
}
let names: Vec<String> = names
.iter()
.map(|n| n.to_string_lossy().into_owned())
.collect();
kept_among(path, names.iter().map(String::as_str))
}
fn kept_among<'e>(
path: &Path,
entries: impl IntoIterator<Item = &'e str>,
) -> Result<Option<IgnoredOutput>, String> {
let mut assets = DeployedAssets::new(path);
let mut kept: Option<IgnoredOutput> = None;
for entry in entries {
let bare = entry.trim_end_matches('/');
if is_harness_path(bare)
|| (!is_asset_path(bare) && (is_regenerable(bare) || under_cache_dir(path, bare)))
{
continue;
}
let files = count_files(path, bare, &mut assets)?;
if files == 0 {
continue; }
let found = kept.get_or_insert_with(|| IgnoredOutput {
files: 0,
first: entry.to_string(),
entries: Vec::new(),
});
found.files += files;
found.entries.push((entry.to_string(), files));
}
Ok(kept)
}
fn ignored_entries(status: &str) -> Vec<&str> {
let mut entries = Vec::new();
let mut records = status.split('\0');
while let Some(record) = records.next() {
let xy = record.as_bytes().get(..2).unwrap_or_default();
if xy.iter().any(|c| matches!(c, b'R' | b'C')) {
records.next();
} else if let Some(entry) = record.strip_prefix("!! ") {
entries.push(entry);
}
}
entries
}
pub(crate) fn ignored_output_refusal(path: &Path) -> Option<String> {
match kept_ignored_output(path) {
Ok(None) => None,
Ok(Some(out)) => Some(format!(
"{} holds {} gitignored file(s) that are not build output (first: `{}`); \
`git worktree remove` would delete them (#8534)",
path.display(),
out.files,
out.first
)),
Err(e) => Some(format!(
"{}: the gitignored-output check failed ({e}); keeping it (#8534)",
path.display()
)),
}
}
pub(crate) fn unversioned_content_refusal(path: &Path) -> Option<String> {
match kept_unversioned_content(path) {
Ok(None) => None,
Ok(Some(out)) => Some(format!(
"{} is not tracked by git and holds {} file(s) that are not harness files or \
build output (first: `{}`); deleting it would lose them (#8663)",
path.display(),
out.files,
out.first
)),
Err(e) => Some(format!(
"{}: the content check failed ({e}); keeping it (#8663)",
path.display()
)),
}
}
pub(crate) fn ignored_output_blocks_removal(
path: &Path,
policy: DirtyWorktreePolicy,
) -> Option<String> {
let refusal = ignored_output_refusal(path)?;
if policy == DirtyWorktreePolicy::Skip {
warn!(path = %path.display(), "worktree removal refused — {refusal}");
return Some(refusal);
}
warn!(
path = %path.display(),
"worktree removal: DISCARDING gitignored output — explicit force-discard opt-in \
was supplied: {refusal}"
);
None
}
pub fn inspect_dirt_with_ignored_output(path: &Path) -> Option<DirtyWorktree> {
let dirt = inspect_dirt(path);
match (dirt, kept_ignored_output(path)) {
(dirt, Ok(None)) => dirt,
(dirt, Ok(Some(out))) => {
let why = format!(
"{} gitignored file(s) that are not build output (first: `{}`)",
out.files, out.first
);
Some(match dirt {
Some(mut d) => {
d.dirty_files += out.files;
d.reason = format!("{}; {why}", d.reason);
d
}
None => DirtyWorktree::new(path, why, out.files, 0),
})
}
(dirt, Err(e)) => {
let why = format!("the gitignored-output check failed: {e}");
let reason = match dirt {
Some(d) => format!("{}; {why}", d.reason),
None => why,
};
Some(DirtyWorktree::new(path, reason, 0, 0))
}
}
}
fn count_files(root: &Path, rel: &str, assets: &mut DeployedAssets) -> Result<usize, String> {
let top = root.join(rel);
let meta = std::fs::symlink_metadata(&top)
.map_err(|e| format!("`{}` is unreadable: {e}", top.display()))?;
if !meta.is_dir() {
return Ok(usize::from(!assets.is_tm_deployed(rel)));
}
let mut count = 0usize;
let mut stack = vec![(top, rel.to_string())];
while let Some((dir, dir_rel)) = stack.pop() {
let entries = std::fs::read_dir(&dir)
.map_err(|e| format!("`{}` is unreadable: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("`{}` is unreadable: {e}", dir.display()))?;
let kind = entry
.file_type()
.map_err(|e| format!("`{}` is unreadable: {e}", entry.path().display()))?;
let name = entry.file_name();
let child_rel = format!("{dir_rel}/{}", name.to_string_lossy());
if is_harness_path(&child_rel) {
continue;
}
if kind.is_dir() {
stack.push((entry.path(), child_rel));
} else if !assets.is_tm_deployed(&child_rel) {
count += 1;
if count >= FILE_COUNT_CAP {
return Ok(count);
}
}
}
}
Ok(count)
}
#[cfg(test)]
#[path = "worktree_ignored_output_tests.rs"]
mod worktree_ignored_output_tests;